@endora-commerce/mod-api-keys 0.100.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +57 -0
- package/dist/admin/index.d.ts +37 -0
- package/dist/admin/index.d.ts.map +1 -0
- package/dist/admin/index.js +43 -0
- package/dist/admin/index.js.map +1 -0
- package/dist/admin/pages/ApiKeysPage.d.ts +3 -0
- package/dist/admin/pages/ApiKeysPage.d.ts.map +1 -0
- package/dist/admin/pages/ApiKeysPage.js +182 -0
- package/dist/admin/pages/ApiKeysPage.js.map +1 -0
- package/dist/backend/entities/api-key.entity.d.ts +33 -0
- package/dist/backend/entities/api-key.entity.d.ts.map +1 -0
- package/dist/backend/entities/api-key.entity.js +118 -0
- package/dist/backend/entities/api-key.entity.js.map +1 -0
- package/dist/backend/index.d.ts +71 -0
- package/dist/backend/index.d.ts.map +1 -0
- package/dist/backend/index.js +57 -0
- package/dist/backend/index.js.map +1 -0
- package/dist/backend/plugin.d.ts +65 -0
- package/dist/backend/plugin.d.ts.map +1 -0
- package/dist/backend/plugin.js +75 -0
- package/dist/backend/plugin.js.map +1 -0
- package/dist/backend/routes.d.ts +26 -0
- package/dist/backend/routes.d.ts.map +1 -0
- package/dist/backend/routes.js +51 -0
- package/dist/backend/routes.js.map +1 -0
- package/dist/backend/services/api-key-service.d.ts +72 -0
- package/dist/backend/services/api-key-service.d.ts.map +1 -0
- package/dist/backend/services/api-key-service.js +186 -0
- package/dist/backend/services/api-key-service.js.map +1 -0
- package/dist/manifest.d.ts +169 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +155 -0
- package/dist/manifest.js.map +1 -0
- package/dist/migrations/20260724T173916_api_keys_distributor_binding.d.ts +50 -0
- package/dist/migrations/20260724T173916_api_keys_distributor_binding.d.ts.map +1 -0
- package/dist/migrations/20260724T173916_api_keys_distributor_binding.js +117 -0
- package/dist/migrations/20260724T173916_api_keys_distributor_binding.js.map +1 -0
- package/dist/migrations/index.d.ts +27 -0
- package/dist/migrations/index.d.ts.map +1 -0
- package/dist/migrations/index.js +29 -0
- package/dist/migrations/index.js.map +1 -0
- package/docs/api_keys.md +89 -0
- package/i18n/en.json +5 -0
- package/i18n/pl.json +5 -0
- package/package.json +93 -0
- package/tailwind.css +14 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { lazyPort } from '@endora-commerce/platform/kernel';
|
|
2
|
+
import { integrationsModule } from './plugin.js';
|
|
3
|
+
import { ApiKey } from './entities/api-key.entity.js';
|
|
4
|
+
export function registerModule(ctx) {
|
|
5
|
+
ctx.di.register({
|
|
6
|
+
// One registration holds the whole graph: the two gates close over the
|
|
7
|
+
// same `resolveScopedKey`, so building them separately would mean two
|
|
8
|
+
// authenticators disagreeing about what a scope means.
|
|
9
|
+
apiKeys: ctx
|
|
10
|
+
.asFunction(({ emFactory, auditLogService }) => integrationsModule({
|
|
11
|
+
emFactory,
|
|
12
|
+
auditLogService,
|
|
13
|
+
// Resolved per check rather than captured: `requireAdmin` is a
|
|
14
|
+
// transient port gate, and a captured one keeps admitting requests
|
|
15
|
+
// after `auth` is switched off.
|
|
16
|
+
requireAdmin: (permission) => async (req, reply) => ctx.cradle().requireAdmin(permission)(req, reply),
|
|
17
|
+
resolveAdminUserId: (request) => ctx.cradle().adminContextResolver(request).adminUserId,
|
|
18
|
+
// Feature 075 (D-87) — the two owners the binding rules ask about.
|
|
19
|
+
// Both were `em.getKnex().raw('select … from "organizations"')` and
|
|
20
|
+
// `… from "customer_accounts"` inside the service: raw SQL naming
|
|
21
|
+
// no specifier, so the boundary compiled and returned rows. Gated
|
|
22
|
+
// ports, so a singleton may not hold one directly.
|
|
23
|
+
bindingPorts: {
|
|
24
|
+
organizations: lazyPort(ctx, 'organizationDetailsPort'),
|
|
25
|
+
customerAccounts: lazyPort(ctx, 'customerAccountReadPort'),
|
|
26
|
+
},
|
|
27
|
+
}))
|
|
28
|
+
.singleton(),
|
|
29
|
+
});
|
|
30
|
+
ctx.di.providePort('apiKeyService', ctx.asFunction(({ apiKeys }) => apiKeys.handle.apiKeyService).singleton());
|
|
31
|
+
ctx.di.providePort('requireApiKey', ctx.asFunction(({ apiKeys }) => apiKeys.handle.requireApiKey).singleton());
|
|
32
|
+
ctx.di.providePort('requireBoundApiKey', ctx.asFunction(({ apiKeys }) => apiKeys.handle.requireBoundApiKey).singleton());
|
|
33
|
+
ctx.di.providePort('apiKeyResolver', ctx
|
|
34
|
+
.asFunction(({ apiKeys }) => async (token) => apiKeys.handle.apiKeyService.authenticate(token))
|
|
35
|
+
.singleton());
|
|
36
|
+
ctx.routes(async (app) => {
|
|
37
|
+
// The factory's own plugin, not a re-registration: it mounts the admin
|
|
38
|
+
// routes and nothing else, and duplicating that here would be a second
|
|
39
|
+
// place to keep in step.
|
|
40
|
+
await ctx.cradle().apiKeys.plugin(app);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The module's persisted entity classes, on the `./backend` subpath, as one
|
|
45
|
+
* array and **no named class export** (D-168).
|
|
46
|
+
*
|
|
47
|
+
* This is the shape the platform reads when the package is *installed*: the
|
|
48
|
+
* boot-time loader (`src/packages/package-runtime.ts`, `exported['entities']`)
|
|
49
|
+
* and the static declaration reader (`scripts/lib/package-declarations.ts`),
|
|
50
|
+
* which is the third source of `check:module-boundary`'s `table→owner` map and
|
|
51
|
+
* the package pass of `check-entity-tenant-classification`. A missing array is
|
|
52
|
+
* answered with `[]` — zero entities registered, no error anywhere.
|
|
53
|
+
*/
|
|
54
|
+
export const entities = [
|
|
55
|
+
ApiKey,
|
|
56
|
+
];
|
|
57
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/backend/index.ts"],"names":[],"mappings":"AAKA,OAAO,EAAE,QAAQ,EAAE,MAAM,kCAAkC,CAAC;AAG5D,OAAO,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AACjD,OAAO,EAAE,MAAM,EAAE,MAAM,8BAA8B,CAAC;AAqDtD,MAAM,UAAU,cAAc,CAAC,GAAkB;IAC/C,GAAG,CAAC,EAAE,CAAC,QAAQ,CAAC;QACd,uEAAuE;QACvE,sEAAsE;QACtE,uDAAuD;QACvD,OAAO,EAAE,GAAG;aACT,UAAU,CACT,CAAC,EAAE,SAAS,EAAE,eAAe,EAAiB,EAAE,EAAE,CAChD,kBAAkB,CAAC;YACjB,SAAS;YACT,eAAe;YACf,+DAA+D;YAC/D,mEAAmE;YACnE,gCAAgC;YAChC,YAAY,EAAE,CAAC,UAAU,EAAE,EAAE,CAAC,KAAK,EAAE,GAAG,EAAE,KAAK,EAAE,EAAE,CACjD,GAAG,CAAC,MAAM,EAAiB,CAAC,YAAY,CAAC,UAAU,CAAC,CAAC,GAAG,EAAE,KAAK,CAAC;YAClE,kBAAkB,EAAE,CAAC,OAAO,EAAE,EAAE,CAC9B,GAAG,CAAC,MAAM,EAAiB,CAAC,oBAAoB,CAAC,OAAO,CAAC,CAAC,WAAW;YACvE,mEAAmE;YACnE,oEAAoE;YACpE,kEAAkE;YAClE,kEAAkE;YAClE,mDAAmD;YACnD,YAAY,EAAE;gBACZ,aAAa,EAAE,QAAQ,CAA0B,GAAG,EAAE,yBAAyB,CAAC;gBAChF,gBAAgB,EAAE,QAAQ,CACxB,GAAG,EACH,yBAAyB,CAC1B;aACF;SACF,CAAC,CACL;aACA,SAAS,EAAE;KACf,CAAC,CAAC;IAEH,GAAG,CAAC,EAAE,CAAC,WAAW,CAChB,eAAe,EACf,GAAG,CAAC,UAAU,CAAC,CAAC,EAAE,OAAO,EAAiB,EAAE,EAAE,CAAC,OAAO,CAAC,MAAM,CAAC,aAAa,CAAC,CAAC,SAAS,EAAE,CACzF,CAAC;IAEF,GAAG,CAAC,EAAE,CAAC,WAAW,CAChB,eAAe,EACf,GAAG,CAAC,UAAU,CAAC,CAAC,EAAE,OAAO,EAAiB,EAAE,EAAE,CAAC,OAAO,CAAC,MAAM,CAAC,aAAa,CAAC,CAAC,SAAS,EAAE,CACzF,CAAC;IAEF,GAAG,CAAC,EAAE,CAAC,WAAW,CAChB,oBAAoB,EACpB,GAAG,CAAC,UAAU,CAAC,CAAC,EAAE,OAAO,EAAiB,EAAE,EAAE,CAAC,OAAO,CAAC,MAAM,CAAC,kBAAkB,CAAC,CAAC,SAAS,EAAE,CAC9F,CAAC;IAEF,GAAG,CAAC,EAAE,CAAC,WAAW,CAChB,gBAAgB,EAChB,GAAG;SACA,UAAU,CACT,CAAC,EAAE,OAAO,EAAiB,EAAE,EAAE,CAC7B,KAAK,EAAE,KAAa,EAAE,EAAE,CACtB,OAAO,CAAC,MAAM,CAAC,aAAa,CAAC,YAAY,CAAC,KAAK,CAAC,CACrD;SACA,SAAS,EAAE,CACf,CAAC;IAEF,GAAG,CAAC,MAAM,CAAC,KAAK,EAAE,GAAG,EAAE,EAAE;QACvB,uEAAuE;QACvE,uEAAuE;QACvE,yBAAyB;QACzB,MAAM,GAAG,CAAC,MAAM,EAAiB,CAAC,OAAO,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;IACxD,CAAC,CAAC,CAAC;AACL,CAAC;AAED;;;;;;;;;;GAUG;AACH,MAAM,CAAC,MAAM,QAAQ,GAAG;IACtB,MAAM;CACP,CAAC"}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
|
2
|
+
import type { EntityManager } from '@mikro-orm/postgresql';
|
|
3
|
+
import { ApiKeyService, type ApiKeyBindingPorts } from './services/api-key-service.js';
|
|
4
|
+
import type { AuditPort } from '@endora-commerce/platform/kernel';
|
|
5
|
+
import type { RequireAdminFactory } from '@endora-commerce/platform/kernel';
|
|
6
|
+
/**
|
|
7
|
+
* Composition root for the US7 surface — API keys + webhooks.
|
|
8
|
+
* Returns a handle so other modules (e.g. catalog routes.api-key.ts) can
|
|
9
|
+
* consume the api-key gate this builds.
|
|
10
|
+
*/
|
|
11
|
+
export interface IntegrationsModuleOptions {
|
|
12
|
+
emFactory: () => EntityManager;
|
|
13
|
+
requireAdmin: RequireAdminFactory;
|
|
14
|
+
auditLogService: AuditPort;
|
|
15
|
+
/**
|
|
16
|
+
* `organizations`' and `customer_accounts`' published reads, which the
|
|
17
|
+
* binding rules B3 and B4 decide on (feature 075, D-87). Both were raw SQL
|
|
18
|
+
* against those modules' tables inside the service.
|
|
19
|
+
*/
|
|
20
|
+
bindingPorts: ApiKeyBindingPorts;
|
|
21
|
+
/**
|
|
22
|
+
* Who the acting admin is, from the production actor (feature 080, T051).
|
|
23
|
+
* Threaded through to the admin routes, which used to read
|
|
24
|
+
* `request.testActor` — see `ApiKeysAdminDeps.resolveAdminUserId`.
|
|
25
|
+
*/
|
|
26
|
+
resolveAdminUserId: (request: FastifyRequest) => string;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Feature 062 — the resolved binding `requireBoundApiKey` stashes on the
|
|
30
|
+
* request for the distributor services.
|
|
31
|
+
*/
|
|
32
|
+
export interface ApiKeyRequestBinding {
|
|
33
|
+
apiKeyId: string;
|
|
34
|
+
scopes: string[];
|
|
35
|
+
organizationId: string;
|
|
36
|
+
salesChannelId: string;
|
|
37
|
+
customerAccountId: string;
|
|
38
|
+
}
|
|
39
|
+
declare module 'fastify' {
|
|
40
|
+
interface FastifyRequest {
|
|
41
|
+
/** Set by `requireBoundApiKey` after a successful binding assertion. */
|
|
42
|
+
apiKeyBinding?: ApiKeyRequestBinding;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export interface IntegrationsModuleHandle {
|
|
46
|
+
apiKeyService: ApiKeyService;
|
|
47
|
+
/**
|
|
48
|
+
* Pre-handler factory that authenticates a Bearer token via ApiKeyService
|
|
49
|
+
* and gates on the requested scope. Used by api-key route surfaces (e.g.
|
|
50
|
+
* the catalog by-sku upsert in routes.api-key.ts) — out-of-scope returns
|
|
51
|
+
* 403 + an audit row tagging the api key (T220).
|
|
52
|
+
*/
|
|
53
|
+
requireApiKey: (scope: string) => (req: FastifyRequest, reply: FastifyReply) => Promise<void>;
|
|
54
|
+
/**
|
|
55
|
+
* Feature 062 — distributor gate: authenticate → scope (audited 403,
|
|
56
|
+
* unchanged) → binding assertion (403 `API_KEY_NOT_BOUND` + audit
|
|
57
|
+
* `api_key.not_bound`) → stash the resolved binding on the request.
|
|
58
|
+
*/
|
|
59
|
+
requireBoundApiKey: (scope: string) => (req: FastifyRequest, reply: FastifyReply) => Promise<void>;
|
|
60
|
+
}
|
|
61
|
+
export declare function integrationsModule(options: IntegrationsModuleOptions): {
|
|
62
|
+
plugin: (app: FastifyInstance) => Promise<void>;
|
|
63
|
+
handle: IntegrationsModuleHandle;
|
|
64
|
+
};
|
|
65
|
+
//# sourceMappingURL=plugin.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../../src/backend/plugin.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAC7E,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAG3D,OAAO,EACL,aAAa,EACb,KAAK,kBAAkB,EAExB,MAAM,+BAA+B,CAAC;AAEvC,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,kCAAkC,CAAC;AAClE,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,kCAAkC,CAAC;AAE5E;;;;GAIG;AAEH,MAAM,WAAW,yBAAyB;IACxC,SAAS,EAAE,MAAM,aAAa,CAAC;IAC/B,YAAY,EAAE,mBAAmB,CAAC;IAClC,eAAe,EAAE,SAAS,CAAC;IAC3B;;;;OAIG;IACH,YAAY,EAAE,kBAAkB,CAAC;IACjC;;;;OAIG;IACH,kBAAkB,EAAE,CAAC,OAAO,EAAE,cAAc,KAAK,MAAM,CAAC;CACzD;AAED;;;GAGG;AACH,MAAM,WAAW,oBAAoB;IACnC,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,cAAc,EAAE,MAAM,CAAC;IACvB,cAAc,EAAE,MAAM,CAAC;IACvB,iBAAiB,EAAE,MAAM,CAAC;CAC3B;AAED,OAAO,QAAQ,SAAS,CAAC;IACvB,UAAU,cAAc;QACtB,wEAAwE;QACxE,aAAa,CAAC,EAAE,oBAAoB,CAAC;KACtC;CACF;AAED,MAAM,WAAW,wBAAwB;IACvC,aAAa,EAAE,aAAa,CAAC;IAC7B;;;;;OAKG;IACH,aAAa,EAAE,CACb,KAAK,EAAE,MAAM,KACV,CAAC,GAAG,EAAE,cAAc,EAAE,KAAK,EAAE,YAAY,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACjE;;;;OAIG;IACH,kBAAkB,EAAE,CAClB,KAAK,EAAE,MAAM,KACV,CAAC,GAAG,EAAE,cAAc,EAAE,KAAK,EAAE,YAAY,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CAClE;AAED,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,yBAAyB,GAAG;IACtE,MAAM,EAAE,CAAC,GAAG,EAAE,eAAe,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAChD,MAAM,EAAE,wBAAwB,CAAC;CAClC,CA4FA"}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { ERROR_CODES } from '@endora-commerce/contracts';
|
|
2
|
+
import { HttpError } from '@endora-commerce/platform/http';
|
|
3
|
+
import { ApiKeyService, } from './services/api-key-service.js';
|
|
4
|
+
import { registerApiKeysAdminRoutes } from './routes.js';
|
|
5
|
+
export function integrationsModule(options) {
|
|
6
|
+
const apiKeyService = new ApiKeyService(options.emFactory, options.bindingPorts, options.auditLogService);
|
|
7
|
+
// Shared authenticate + scope assertion used by both gates.
|
|
8
|
+
const resolveScopedKey = async (request, scope) => {
|
|
9
|
+
const header = request.headers.authorization;
|
|
10
|
+
if (!header?.startsWith('Bearer ')) {
|
|
11
|
+
throw new HttpError(401, ERROR_CODES.UNAUTHORIZED, 'API key required.');
|
|
12
|
+
}
|
|
13
|
+
const token = header.slice('Bearer '.length).trim();
|
|
14
|
+
const resolved = await apiKeyService.authenticate(token);
|
|
15
|
+
if (!resolved) {
|
|
16
|
+
throw new HttpError(401, ERROR_CODES.UNAUTHORIZED, 'API key is invalid or revoked.');
|
|
17
|
+
}
|
|
18
|
+
if (!resolved.scopes.includes(scope)) {
|
|
19
|
+
if (options.auditLogService) {
|
|
20
|
+
await options.auditLogService.record({
|
|
21
|
+
action: 'api_key.out_of_scope',
|
|
22
|
+
objectType: 'api_key',
|
|
23
|
+
objectId: resolved.apiKeyId,
|
|
24
|
+
stateBefore: null,
|
|
25
|
+
stateAfter: { attemptedScope: scope, grantedScopes: resolved.scopes },
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
throw new HttpError(403, ERROR_CODES.API_KEY_OUT_OF_SCOPE, `API key lacks the required scope: ${scope}.`);
|
|
29
|
+
}
|
|
30
|
+
return resolved;
|
|
31
|
+
};
|
|
32
|
+
const requireApiKey = (scope) => async (request) => {
|
|
33
|
+
await resolveScopedKey(request, scope);
|
|
34
|
+
};
|
|
35
|
+
// Feature 062 — distributor gate (contracts/api-key-binding.md §4).
|
|
36
|
+
const requireBoundApiKey = (scope) => async (request) => {
|
|
37
|
+
const resolved = await resolveScopedKey(request, scope);
|
|
38
|
+
if (!resolved.organizationId ||
|
|
39
|
+
!resolved.salesChannelId ||
|
|
40
|
+
!resolved.customerAccountId) {
|
|
41
|
+
if (options.auditLogService) {
|
|
42
|
+
await options.auditLogService.record({
|
|
43
|
+
action: 'api_key.not_bound',
|
|
44
|
+
objectType: 'api_key',
|
|
45
|
+
objectId: resolved.apiKeyId,
|
|
46
|
+
stateBefore: null,
|
|
47
|
+
stateAfter: { attemptedScope: scope },
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
throw new HttpError(403, ERROR_CODES.API_KEY_NOT_BOUND, 'This endpoint requires a distributor-bound API key.');
|
|
51
|
+
}
|
|
52
|
+
request.apiKeyBinding = {
|
|
53
|
+
apiKeyId: resolved.apiKeyId,
|
|
54
|
+
scopes: resolved.scopes,
|
|
55
|
+
organizationId: resolved.organizationId,
|
|
56
|
+
salesChannelId: resolved.salesChannelId,
|
|
57
|
+
customerAccountId: resolved.customerAccountId,
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
return {
|
|
61
|
+
handle: {
|
|
62
|
+
apiKeyService,
|
|
63
|
+
requireApiKey,
|
|
64
|
+
requireBoundApiKey,
|
|
65
|
+
},
|
|
66
|
+
plugin: async (app) => {
|
|
67
|
+
await registerApiKeysAdminRoutes(app, {
|
|
68
|
+
apiKeyService,
|
|
69
|
+
requireAdmin: options.requireAdmin,
|
|
70
|
+
resolveAdminUserId: options.resolveAdminUserId,
|
|
71
|
+
});
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
//# sourceMappingURL=plugin.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"plugin.js","sourceRoot":"","sources":["../../src/backend/plugin.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
|
2
|
+
import type { ApiKeyService } from './services/api-key-service.js';
|
|
3
|
+
import type { RequireAdminFactory } from '@endora-commerce/platform/kernel';
|
|
4
|
+
export interface ApiKeysAdminDeps {
|
|
5
|
+
apiKeyService: ApiKeyService;
|
|
6
|
+
requireAdmin: RequireAdminFactory;
|
|
7
|
+
/**
|
|
8
|
+
* The acting admin's id, resolved from the request the guard in front of
|
|
9
|
+
* these routes has already accepted.
|
|
10
|
+
*
|
|
11
|
+
* Injected rather than read here, because the read used to be
|
|
12
|
+
* `testAdminUserId(request)` — `http/test-actor-carrier`, which
|
|
13
|
+
* `contracts/host-package.md` §1.4j classifies **A**: the file exists to
|
|
14
|
+
* narrow this repository's test-harness Fastify augmentation, and an
|
|
15
|
+
* installed package has no relationship to that harness. It also answered
|
|
16
|
+
* `undefined` in production for every request, because nothing under `src/`
|
|
17
|
+
* writes `request.testActor`.
|
|
18
|
+
*
|
|
19
|
+
* Both composition roots supply it from `adminContextResolver`, which reads
|
|
20
|
+
* the production actor and throws 401 for a non-admin. Every call site sits
|
|
21
|
+
* behind `requireAdmin(...)`, so the actor is an admin by the time it runs.
|
|
22
|
+
*/
|
|
23
|
+
resolveAdminUserId: (request: FastifyRequest) => string;
|
|
24
|
+
}
|
|
25
|
+
export declare function registerApiKeysAdminRoutes(app: FastifyInstance, deps: ApiKeysAdminDeps): Promise<void>;
|
|
26
|
+
//# sourceMappingURL=routes.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../../src/backend/routes.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAE/D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,+BAA+B,CAAC;AAEnE,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,kCAAkC,CAAC;AAE5E,MAAM,WAAW,gBAAgB;IAC/B,aAAa,EAAE,aAAa,CAAC;IAC7B,YAAY,EAAE,mBAAmB,CAAC;IAClC;;;;;;;;;;;;;;;OAeG;IACH,kBAAkB,EAAE,CAAC,OAAO,EAAE,cAAc,KAAK,MAAM,CAAC;CACzD;AAED,wBAAsB,0BAA0B,CAC9C,GAAG,EAAE,eAAe,EACpB,IAAI,EAAE,gBAAgB,GACrB,OAAO,CAAC,IAAI,CAAC,CA6Cf"}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { createApiKeyRequestSchema } from '@endora-commerce/contracts';
|
|
2
|
+
export async function registerApiKeysAdminRoutes(app, deps) {
|
|
3
|
+
const { apiKeyService, requireAdmin, resolveAdminUserId } = deps;
|
|
4
|
+
app.get('/api/v1/admin/api-keys', { preHandler: requireAdmin('integrations:manage') }, async () => {
|
|
5
|
+
const rows = await apiKeyService.list();
|
|
6
|
+
return { data: rows.map(serializeApiKey) };
|
|
7
|
+
});
|
|
8
|
+
app.post('/api/v1/admin/api-keys', {
|
|
9
|
+
preHandler: requireAdmin('integrations:manage'),
|
|
10
|
+
schema: { body: createApiKeyRequestSchema },
|
|
11
|
+
}, async (request, reply) => {
|
|
12
|
+
const body = createApiKeyRequestSchema.parse(request.body);
|
|
13
|
+
const result = await apiKeyService.create({
|
|
14
|
+
name: body.name,
|
|
15
|
+
scopes: body.scopes,
|
|
16
|
+
...(body.binding !== undefined ? { binding: body.binding } : {}),
|
|
17
|
+
...(body.expiresAt !== undefined ? { expiresAt: new Date(body.expiresAt) } : {}),
|
|
18
|
+
createdByAdminUserId: resolveAdminUserId(request),
|
|
19
|
+
});
|
|
20
|
+
reply.status(201);
|
|
21
|
+
return {
|
|
22
|
+
data: {
|
|
23
|
+
apiKey: serializeApiKey(result.apiKey),
|
|
24
|
+
bearerToken: result.bearerToken,
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
});
|
|
28
|
+
app.delete('/api/v1/admin/api-keys/:id', { preHandler: requireAdmin('integrations:manage') }, async (request, reply) => {
|
|
29
|
+
await apiKeyService.revoke(request.params.id);
|
|
30
|
+
return reply.status(204).send();
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
function serializeApiKey(a) {
|
|
34
|
+
return {
|
|
35
|
+
id: a.id,
|
|
36
|
+
name: a.name,
|
|
37
|
+
lastFour: a.lastFour,
|
|
38
|
+
scopes: a.scopes,
|
|
39
|
+
status: a.status,
|
|
40
|
+
lastUsedAt: a.lastUsedAt?.toISOString() ?? null,
|
|
41
|
+
revokedAt: a.revokedAt?.toISOString() ?? null,
|
|
42
|
+
createdAt: a.createdAt.toISOString(),
|
|
43
|
+
updatedAt: a.updatedAt.toISOString(),
|
|
44
|
+
// Feature 062 — additive binding/expiry fields (null for unbound keys).
|
|
45
|
+
organizationId: a.organizationId ?? null,
|
|
46
|
+
salesChannelId: a.salesChannelId ?? null,
|
|
47
|
+
customerAccountId: a.customerAccountId ?? null,
|
|
48
|
+
expiresAt: a.expiresAt?.toISOString() ?? null,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
//# sourceMappingURL=routes.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"routes.js","sourceRoot":"","sources":["../../src/backend/routes.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/postgresql';
|
|
2
|
+
import { type ApiKeyBinding, type CustomerAccountReadPort, type OrganizationDetailsPort } from '@endora-commerce/contracts';
|
|
3
|
+
import { ApiKey } from '../entities/api-key.entity.js';
|
|
4
|
+
import type { AuditPort } from '@endora-commerce/platform/kernel';
|
|
5
|
+
/**
|
|
6
|
+
* ApiKeyService (T227, extended by feature 062 — distributor binding).
|
|
7
|
+
*
|
|
8
|
+
* - create: generates a `sk_live_<random>` token, stores its sha256 hash, and
|
|
9
|
+
* returns the raw token + ApiKey row. Caller must show the token once.
|
|
10
|
+
* Validates the distributor binding rules B1–B5
|
|
11
|
+
* (specs/062-distributor-api/contracts/api-key-binding.md §2).
|
|
12
|
+
* - revoke: marks status='revoked', sets revokedAt.
|
|
13
|
+
* - authenticate: hashes the supplied bearer, looks up by hash, validates
|
|
14
|
+
* status + expiry. Returns the resolved identity (incl. binding) or null.
|
|
15
|
+
* Used by the api-key auth gates and the ambient actor resolver.
|
|
16
|
+
*/
|
|
17
|
+
export interface CreateApiKeyResult {
|
|
18
|
+
apiKey: ApiKey;
|
|
19
|
+
/** Raw bearer token. Returned exactly once. */
|
|
20
|
+
bearerToken: string;
|
|
21
|
+
}
|
|
22
|
+
/** Resolved identity of an authenticated key, incl. the distributor binding. */
|
|
23
|
+
export interface AuthenticatedApiKey {
|
|
24
|
+
apiKeyId: string;
|
|
25
|
+
scopes: string[];
|
|
26
|
+
organizationId: string | null;
|
|
27
|
+
salesChannelId: string | null;
|
|
28
|
+
customerAccountId: string | null;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The two owners this module asks about a binding it is being handed
|
|
32
|
+
* (feature 075, D-87).
|
|
33
|
+
*
|
|
34
|
+
* Both reads were `em.getKnex().raw('select … from "organizations" / …
|
|
35
|
+
* "customer_accounts" …')`. Raw SQL names no import specifier, so those two
|
|
36
|
+
* boundary crossings compiled, returned rows and were invisible to the import
|
|
37
|
+
* predicate until feature 077 taught the check to read table identifiers.
|
|
38
|
+
*
|
|
39
|
+
* Nothing about the isolation changes by resolving them as ports: `getKnex()`
|
|
40
|
+
* is `getConnection().getKnex()`, which takes its own pooled connection, so
|
|
41
|
+
* neither read could ever see the enclosing transaction's writes either. What
|
|
42
|
+
* changes is that an absent owner now refuses instead of answering — B3 and B4
|
|
43
|
+
* decide whether a distributor key may be minted, and a validation that
|
|
44
|
+
* silently found no organisation because the module holding organisations was
|
|
45
|
+
* gone would mint nothing and blame the operator's input.
|
|
46
|
+
*/
|
|
47
|
+
export interface ApiKeyBindingPorts {
|
|
48
|
+
organizations: OrganizationDetailsPort;
|
|
49
|
+
customerAccounts: CustomerAccountReadPort;
|
|
50
|
+
}
|
|
51
|
+
export declare class ApiKeyService {
|
|
52
|
+
#private;
|
|
53
|
+
private readonly emFactory;
|
|
54
|
+
private readonly bindingPorts;
|
|
55
|
+
private readonly auditLog?;
|
|
56
|
+
constructor(emFactory: () => EntityManager, bindingPorts: ApiKeyBindingPorts, auditLog?: AuditPort | undefined);
|
|
57
|
+
create(input: {
|
|
58
|
+
name: string;
|
|
59
|
+
scopes: string[];
|
|
60
|
+
binding?: ApiKeyBinding;
|
|
61
|
+
expiresAt?: Date;
|
|
62
|
+
createdByAdminUserId?: string;
|
|
63
|
+
}): Promise<CreateApiKeyResult>;
|
|
64
|
+
list(): Promise<ApiKey[]>;
|
|
65
|
+
revoke(apiKeyId: string): Promise<void>;
|
|
66
|
+
/**
|
|
67
|
+
* Resolve a raw bearer token to the key's identity. Returns null if
|
|
68
|
+
* invalid / revoked / expired. Updates `lastUsedAt` on success.
|
|
69
|
+
*/
|
|
70
|
+
authenticate(rawBearer: string): Promise<AuthenticatedApiKey | null>;
|
|
71
|
+
}
|
|
72
|
+
//# sourceMappingURL=api-key-service.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"api-key-service.d.ts","sourceRoot":"","sources":["../../../src/backend/services/api-key-service.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAC3D,OAAO,EAEL,KAAK,aAAa,EAClB,KAAK,uBAAuB,EAC5B,KAAK,uBAAuB,EAC7B,MAAM,4BAA4B,CAAC;AAEpC,OAAO,EAAE,MAAM,EAAE,MAAM,+BAA+B,CAAC;AAGvD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,kCAAkC,CAAC;AAElE;;;;;;;;;;;GAWG;AAEH,MAAM,WAAW,kBAAkB;IACjC,MAAM,EAAE,MAAM,CAAC;IACf,+CAA+C;IAC/C,WAAW,EAAE,MAAM,CAAC;CACrB;AAED,gFAAgF;AAChF,MAAM,WAAW,mBAAmB;IAClC,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,iBAAiB,EAAE,MAAM,GAAG,IAAI,CAAC;CAClC;AAMD;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,WAAW,kBAAkB;IACjC,aAAa,EAAE,uBAAuB,CAAC;IACvC,gBAAgB,EAAE,uBAAuB,CAAC;CAC3C;AAED,qBAAa,aAAa;;IAEtB,OAAO,CAAC,QAAQ,CAAC,SAAS;IAC1B,OAAO,CAAC,QAAQ,CAAC,YAAY;IAC7B,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAC;gBAFT,SAAS,EAAE,MAAM,aAAa,EAC9B,YAAY,EAAE,kBAAkB,EAChC,QAAQ,CAAC,EAAE,SAAS,YAAA;IASjC,MAAM,CAAC,KAAK,EAAE;QAClB,IAAI,EAAE,MAAM,CAAC;QACb,MAAM,EAAE,MAAM,EAAE,CAAC;QACjB,OAAO,CAAC,EAAE,aAAa,CAAC;QACxB,SAAS,CAAC,EAAE,IAAI,CAAC;QACjB,oBAAoB,CAAC,EAAE,MAAM,CAAC;KAC/B,GAAG,OAAO,CAAC,kBAAkB,CAAC;IA0IzB,IAAI,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC;IAKzB,MAAM,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAY7C;;;OAGG;IACG,YAAY,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,mBAAmB,GAAG,IAAI,CAAC;CAoB3E"}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'crypto';
|
|
2
|
+
import { ERROR_CODES, } from '@endora-commerce/contracts';
|
|
3
|
+
import { HttpError } from '@endora-commerce/platform/http';
|
|
4
|
+
import { ApiKey } from '../entities/api-key.entity.js';
|
|
5
|
+
import { recordAuditFromContext } from '@endora-commerce/platform/commands';
|
|
6
|
+
import { SalesChannel } from '@endora-commerce/platform/kernel';
|
|
7
|
+
const TOKEN_PREFIX = 'sk_live_';
|
|
8
|
+
export class ApiKeyService {
|
|
9
|
+
emFactory;
|
|
10
|
+
bindingPorts;
|
|
11
|
+
auditLog;
|
|
12
|
+
constructor(emFactory, bindingPorts, auditLog) {
|
|
13
|
+
this.emFactory = emFactory;
|
|
14
|
+
this.bindingPorts = bindingPorts;
|
|
15
|
+
this.auditLog = auditLog;
|
|
16
|
+
}
|
|
17
|
+
#audit(em, action, objectId, stateBefore, stateAfter) {
|
|
18
|
+
if (this.auditLog) {
|
|
19
|
+
recordAuditFromContext(this.auditLog, em, { action, objectType: 'api_key', objectId, stateBefore, stateAfter });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
async create(input) {
|
|
23
|
+
const em = this.emFactory();
|
|
24
|
+
await this.#validateCreate(em, input);
|
|
25
|
+
const raw = randomBytes(32).toString('base64url');
|
|
26
|
+
const bearerToken = `${TOKEN_PREFIX}${raw}`;
|
|
27
|
+
const apiKey = em.create(ApiKey, {
|
|
28
|
+
name: input.name,
|
|
29
|
+
keyHash: sha256Hex(bearerToken),
|
|
30
|
+
lastFour: bearerToken.slice(-4),
|
|
31
|
+
scopes: input.scopes,
|
|
32
|
+
...(input.binding
|
|
33
|
+
? {
|
|
34
|
+
organizationId: input.binding.organizationId,
|
|
35
|
+
salesChannelId: input.binding.salesChannelId,
|
|
36
|
+
customerAccountId: input.binding.customerAccountId,
|
|
37
|
+
}
|
|
38
|
+
: {}),
|
|
39
|
+
...(input.expiresAt !== undefined ? { expiresAt: input.expiresAt } : {}),
|
|
40
|
+
...(input.createdByAdminUserId !== undefined
|
|
41
|
+
? { createdByAdminUserId: input.createdByAdminUserId }
|
|
42
|
+
: {}),
|
|
43
|
+
});
|
|
44
|
+
em.persist(apiKey);
|
|
45
|
+
this.#audit(em, 'api_key.create', apiKey.id, null, {
|
|
46
|
+
name: apiKey.name,
|
|
47
|
+
scopes: apiKey.scopes,
|
|
48
|
+
binding: input.binding
|
|
49
|
+
? {
|
|
50
|
+
organizationId: input.binding.organizationId,
|
|
51
|
+
salesChannelId: input.binding.salesChannelId,
|
|
52
|
+
customerAccountId: input.binding.customerAccountId,
|
|
53
|
+
}
|
|
54
|
+
: null,
|
|
55
|
+
expiresAt: input.expiresAt?.toISOString() ?? null,
|
|
56
|
+
});
|
|
57
|
+
await em.flush();
|
|
58
|
+
return { apiKey, bearerToken };
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Creation rules B1–B5 (contracts/api-key-binding.md §2). The two foreign
|
|
62
|
+
* rows are read through their owners' published ports (see
|
|
63
|
+
* {@link ApiKeyBindingPorts}); the sales channel is the kernel's own entity
|
|
64
|
+
* and stays an ORM read on the caller's `em`.
|
|
65
|
+
*/
|
|
66
|
+
async #validateCreate(em, input) {
|
|
67
|
+
const issues = [];
|
|
68
|
+
const hasOrdersScope = input.scopes.some((s) => s.startsWith('orders:'));
|
|
69
|
+
if (hasOrdersScope && !input.binding) {
|
|
70
|
+
// B1
|
|
71
|
+
issues.push({
|
|
72
|
+
path: 'binding',
|
|
73
|
+
issue: 'Orders scopes require a distributor binding (organization, sales channel, service account).',
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
if (input.binding && input.scopes.includes('catalog:write')) {
|
|
77
|
+
// B2
|
|
78
|
+
issues.push({
|
|
79
|
+
path: 'scopes',
|
|
80
|
+
issue: 'catalog:write is not allowed on a bound key — PIM writes stay unbound-only.',
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
if (input.binding) {
|
|
84
|
+
const organization = await this.bindingPorts.organizations.findById(input.binding.organizationId);
|
|
85
|
+
// B4. `deletedAt` is checked here rather than assumed of the port: the
|
|
86
|
+
// statement this replaced spelled `and "deleted_at" is null`, and a
|
|
87
|
+
// soft-deleted organisation is a binding target that must not resolve.
|
|
88
|
+
if (organization === null || organization.deletedAt !== null) {
|
|
89
|
+
issues.push({
|
|
90
|
+
path: 'binding.organizationId',
|
|
91
|
+
issue: 'Organization does not exist.',
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* The kernel's own entity, not `select 1 from "sales_channels"` (feature
|
|
96
|
+
* 075, D-87). `sales_channels` is the kernel's table since feature 072
|
|
97
|
+
* moved the resolution machinery there, and a module relating into the
|
|
98
|
+
* kernel by ORM is the sanctioned access.
|
|
99
|
+
*
|
|
100
|
+
* Read through the caller's `em`, not through
|
|
101
|
+
* `salesChannelResolutionPort.getById`: that accessor forks its own
|
|
102
|
+
* EntityManager, so a channel created earlier in this Command's
|
|
103
|
+
* transaction would not be visible to it and B4 would refuse a binding
|
|
104
|
+
* that is about to be valid.
|
|
105
|
+
*/
|
|
106
|
+
const channel = await em.findOne(SalesChannel, { id: input.binding.salesChannelId });
|
|
107
|
+
if (channel === null) {
|
|
108
|
+
// B4 (active not required at creation — the key fails closed while inactive)
|
|
109
|
+
issues.push({
|
|
110
|
+
path: 'binding.salesChannelId',
|
|
111
|
+
issue: 'Sales channel does not exist.',
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
// The wider read on purpose: the four columns the statement selected are
|
|
115
|
+
// all on the record, and the three-way "active" predicate below is this
|
|
116
|
+
// module's rule to state, not the port's `activeOnly` shorthand — that
|
|
117
|
+
// one excludes soft-deleted accounts and says nothing about blocked or
|
|
118
|
+
// anonymized ones.
|
|
119
|
+
const account = await this.bindingPorts.customerAccounts.findById(input.binding.customerAccountId);
|
|
120
|
+
const accountActive = account !== null &&
|
|
121
|
+
account.blockedAt === null &&
|
|
122
|
+
account.deletedAt === null &&
|
|
123
|
+
account.anonymizedAt === null;
|
|
124
|
+
if (!account || !accountActive || account.organizationId !== input.binding.organizationId) {
|
|
125
|
+
// B3
|
|
126
|
+
issues.push({
|
|
127
|
+
path: 'binding.customerAccountId',
|
|
128
|
+
issue: 'Service account must be an active customer account of the bound organization.',
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (input.expiresAt !== undefined && input.expiresAt.getTime() <= Date.now()) {
|
|
133
|
+
// B5
|
|
134
|
+
issues.push({ path: 'expiresAt', issue: 'Expiry must be a future instant.' });
|
|
135
|
+
}
|
|
136
|
+
if (issues.length > 0) {
|
|
137
|
+
throw new HttpError(422, ERROR_CODES.VALIDATION_FAILED, 'API key creation failed binding validation.', issues);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
async list() {
|
|
141
|
+
const em = this.emFactory();
|
|
142
|
+
return em.find(ApiKey, {}, { orderBy: { createdAt: 'desc' } });
|
|
143
|
+
}
|
|
144
|
+
async revoke(apiKeyId) {
|
|
145
|
+
const em = this.emFactory();
|
|
146
|
+
const apiKey = await em.findOne(ApiKey, { id: apiKeyId });
|
|
147
|
+
if (!apiKey) {
|
|
148
|
+
throw new HttpError(404, ERROR_CODES.NOT_FOUND, 'API key not found.');
|
|
149
|
+
}
|
|
150
|
+
apiKey.status = 'revoked';
|
|
151
|
+
apiKey.revokedAt = new Date();
|
|
152
|
+
this.#audit(em, 'api_key.revoke', apiKey.id, { name: apiKey.name }, null);
|
|
153
|
+
await em.flush();
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Resolve a raw bearer token to the key's identity. Returns null if
|
|
157
|
+
* invalid / revoked / expired. Updates `lastUsedAt` on success.
|
|
158
|
+
*/
|
|
159
|
+
async authenticate(rawBearer) {
|
|
160
|
+
// command-coverage-ignore: stamps last-used — high-volume auth bookkeeping,
|
|
161
|
+
// not an audited domain-state mutation.
|
|
162
|
+
if (!rawBearer.startsWith(TOKEN_PREFIX))
|
|
163
|
+
return null;
|
|
164
|
+
const em = this.emFactory();
|
|
165
|
+
const apiKey = await em.findOne(ApiKey, { keyHash: sha256Hex(rawBearer) });
|
|
166
|
+
if (!apiKey || apiKey.status !== 'active')
|
|
167
|
+
return null;
|
|
168
|
+
// Feature 062 (B5 runtime half) — an expired key is indistinguishable from
|
|
169
|
+
// an invalid one (401 UNAUTHORIZED at every gate).
|
|
170
|
+
if (apiKey.expiresAt && apiKey.expiresAt.getTime() <= Date.now())
|
|
171
|
+
return null;
|
|
172
|
+
apiKey.lastUsedAt = new Date();
|
|
173
|
+
await em.flush();
|
|
174
|
+
return {
|
|
175
|
+
apiKeyId: apiKey.id,
|
|
176
|
+
scopes: apiKey.scopes,
|
|
177
|
+
organizationId: apiKey.organizationId ?? null,
|
|
178
|
+
salesChannelId: apiKey.salesChannelId ?? null,
|
|
179
|
+
customerAccountId: apiKey.customerAccountId ?? null,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
function sha256Hex(input) {
|
|
184
|
+
return createHash('sha256').update(input, 'utf8').digest('hex');
|
|
185
|
+
}
|
|
186
|
+
//# sourceMappingURL=api-key-service.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"api-key-service.js","sourceRoot":"","sources":["../../../src/backend/services/api-key-service.ts"],"names":[],"mappings":"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"}
|