deploteka-app-fleet-express
v0.2.0
Published
Drop-in multi-tenant adapter for @shopify/shopify-app-express — per-shop credential dispatch plus the DeploTeka fleet wire contract (imported from deploteka-app-fleet, never copied), so one Express deployment serves a whole fleet of dedicated Shopify apps
Readme
deploteka-app-fleet-express
Runtime authentication adapter for applications onboarded with DeploTeka that run on
@shopify/shopify-app-express. It resolves the dedicated Shopify app credentials
for each shop, dispatches every middleware to that shop's own app instance, and keeps
a local credential replica so application authentication does not depend on DeploTeka
being online.
DeploTeka captures apps as they are. There is no rewrite and no framework migration: your route wiring stays exactly as you wrote it.
npm install deploteka-app-fleet-expressnpx deploteka onboard . detects an Express app and prints the integration recipe;
unlike the Remix/React-Router path it never modifies your code, because Express apps
vary too much in layout for a safe codemod. Full walkthrough:
https://deploteka.com/guides/express-fleet-adapter
The two changes
1. Swap shopifyApp() for shopifyAppFleet().
-import { shopifyApp } from '@shopify/shopify-app-express';
+import { shopifyAppFleet, credentialResolver, prismaCredentialStore } from 'deploteka-app-fleet-express';
-const shopify = shopifyApp({
+const store = prismaCredentialStore(prisma);
+const shopify = shopifyAppFleet({
api: { apiVersion: LATEST_API_VERSION, scopes: process.env.SCOPES?.split(',') },
auth: { path: '/api/auth', callbackPath: '/api/auth/callback' },
webhooks: { path: '/api/webhooks' },
sessionStorage,
+ credentials: credentialResolver(store, async () => null),
+ baseCredentials: {
+ clientId: process.env.SHOPIFY_API_KEY,
+ secret: process.env.SHOPIFY_API_SECRET,
+ appUrl: process.env.HOST,
+ },
});Every route line below that is untouched — shopify.config.auth.path,
shopify.auth.begin(), shopify.processWebhooks({webhookHandlers}),
shopify.validateAuthenticatedSession(), shopify.cspHeaders(),
shopify.ensureInstalledOnShop() all keep working, now per shop.
2. Mount the fleet contract routes.
import { createFleetRouter } from 'deploteka-app-fleet-express';
app.use(
createFleetRouter({
store,
token: process.env.FLEET_REGISTER_TOKEN,
sessionStorage,
onRegistered: (shop) => shopify.invalidateShop(shop),
})
);That serves POST /api/fleet/register and GET /api/fleet/installed — the two
routes DeploTeka calls to hand the app a newly provisioned store and to read its
install state.
Do not skip onRegistered. register is an idempotent upsert, so it is also how
a rotated secret arrives — and a rotation keeps the same client_id, which is the
instance cache's key. Without the hook, a shop whose instance was already built keeps
verifying against the old secret until the process restarts. The automatic
rotation self-heal cannot cover it: that triggers on a thrown auth failure, and
Express's Shopify middleware answers a bad webhook HMAC with a 401 response rather
than throwing. This was found by running the real Shopify Express template, not in
review.
How per-shop dispatch works
@shopify/shopify-app-express's shopifyApp() binds apiKey/apiSecretKey/
hostName at construction, so one instance can only ever speak for one Shopify app.
shopifyAppFleet() keeps the same public surface but turns every middleware into a
dispatcher:
- resolve the requesting shop —
X-Shopify-Shop-Domainheader (webhooks), then the session-token JWT, thenshop/hostparams, then cookies/Referer; - look that shop's credentials up in the local replica (with optional pull-through to DeploTeka on a miss);
- get-or-build that shop's
shopifyApp()instance, cached byclient_idand sharing the one session storage; - run that instance's real middleware for the request.
Unresolved or not-yet-registered shops fall back to baseCredentials — your existing
env pair — so nothing 401s mid-migration.
A thrown auth failure self-heals: the shop's credentials are refreshed once, the
instance rebuilt and the call retried — never if the response was already written.
Express middleware that answers with a 401 instead of throwing cannot trigger that,
which is exactly what onRegistered above is for.
Credential store
With Prisma, add a FleetStore model and use the built-in adapter:
model FleetStore {
shop String @id
clientId String
apiSecretKey String
applicationUrl String
}const store = prismaCredentialStore(prisma);Without Prisma, implement the CredentialStore interface against whatever database
you already have — two methods:
interface CredentialStore {
get(shop: string): Promise<{ clientId: string; secret: string; appUrl: string } | null>;
put(shop: string, creds: { clientId: string; secret: string; appUrl: string }): Promise<void>;
}and wrap it in a CredentialResolver (two more: get and refresh).
credentialResolver(store, factoryFetch) builds one for you; pass
factoryCredentialSource({url, token}) as the second argument to enable pull-through
from DeploTeka, or async () => null to run purely local-first.
Relationship to deploteka-app-fleet
This is not a fork. The fleet wire contract (POST /api/fleet/register,
GET /api/fleet/installed, contractVersion: 1, the secretFingerprint algorithm),
the shop resolver and the per-shop instance cache are all imported from
deploteka-app-fleet, which this package depends on. Its tests assert that by
function identity, not by behaviour — there is exactly one implementation of the
contract across every DeploTeka runtime, so the Express, Remix, React Router and
legacy adapters cannot drift apart.
It exists as a separate package because npm allows a single peer range per package
name, and this adapter peers on @shopify/shopify-app-express and express.
Compatibility
| Peer | Range | Why |
|---|---|---|
| @shopify/shopify-app-express | >=4 <9 | Every member this adapter touches — the shopifyApp() config keys and the returned auth.begin/callback, processWebhooks, validateAuthenticatedSession, cspHeaders, ensureInstalledOnShop, redirectToShopifyOrAppRoot, redirectOutOfApp — is identical in the published type declarations of 4.1.6, 5.0.20, 6.0.5, 7.0.1 and 8.0.0. Verified against 8.0.0. |
| express | ^4.17 \|\| ^5 | Only Router, Request, Response, RequestHandler are used, and only in ways both majors share. |
ensureValidOfflineSession() and registerWebhooks() exist only from
@shopify/shopify-app-express 8. They are exposed unconditionally and throw a named
error on older majors, rather than being silently absent.
Published as both ESM and CommonJS: the official Shopify Express template and most
agency Express backends are CommonJS, so the require() build is what makes the
runtime actually loadable at boot.
Documentation: https://deploteka.com
