@sudobility/shapeshyft_service 1.0.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/CLAUDE.md +35 -0
- package/README.md +17 -0
- package/dist/context.d.ts +62 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +55 -0
- package/dist/context.js.map +1 -0
- package/dist/contracts.d.ts +110 -0
- package/dist/contracts.d.ts.map +1 -0
- package/dist/contracts.js +7 -0
- package/dist/contracts.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/api-key.d.ts +22 -0
- package/dist/lib/api-key.d.ts.map +1 -0
- package/dist/lib/api-key.js +87 -0
- package/dist/lib/api-key.js.map +1 -0
- package/dist/lib/encryption.d.ts +22 -0
- package/dist/lib/encryption.d.ts.map +1 -0
- package/dist/lib/encryption.js +41 -0
- package/dist/lib/encryption.js.map +1 -0
- package/dist/lib/entity-api-key.d.ts +21 -0
- package/dist/lib/entity-api-key.d.ts.map +1 -0
- package/dist/lib/entity-api-key.js +54 -0
- package/dist/lib/entity-api-key.js.map +1 -0
- package/dist/lib/entity-helpers.d.ts +90 -0
- package/dist/lib/entity-helpers.d.ts.map +1 -0
- package/dist/lib/entity-helpers.js +136 -0
- package/dist/lib/entity-helpers.js.map +1 -0
- package/dist/lib/money.d.ts +7 -0
- package/dist/lib/money.d.ts.map +1 -0
- package/dist/lib/money.js +9 -0
- package/dist/lib/money.js.map +1 -0
- package/dist/lib/public-project.d.ts +14 -0
- package/dist/lib/public-project.d.ts.map +1 -0
- package/dist/lib/public-project.js +17 -0
- package/dist/lib/public-project.js.map +1 -0
- package/dist/lib/user-api-key-cache.d.ts +35 -0
- package/dist/lib/user-api-key-cache.d.ts.map +1 -0
- package/dist/lib/user-api-key-cache.js +99 -0
- package/dist/lib/user-api-key-cache.js.map +1 -0
- package/dist/lib/user-api-key.d.ts +46 -0
- package/dist/lib/user-api-key.d.ts.map +1 -0
- package/dist/lib/user-api-key.js +102 -0
- package/dist/lib/user-api-key.js.map +1 -0
- package/dist/middleware/firebaseAuth.d.ts +34 -0
- package/dist/middleware/firebaseAuth.d.ts.map +1 -0
- package/dist/middleware/firebaseAuth.js +133 -0
- package/dist/middleware/firebaseAuth.js.map +1 -0
- package/dist/middleware/rateLimit.d.ts +29 -0
- package/dist/middleware/rateLimit.d.ts.map +1 -0
- package/dist/middleware/rateLimit.js +85 -0
- package/dist/middleware/rateLimit.js.map +1 -0
- package/dist/middleware/subscription.d.ts +8 -0
- package/dist/middleware/subscription.d.ts.map +1 -0
- package/dist/middleware/subscription.js +19 -0
- package/dist/middleware/subscription.js.map +1 -0
- package/dist/routes/ai.d.ts +10 -0
- package/dist/routes/ai.d.ts.map +1 -0
- package/dist/routes/ai.js +698 -0
- package/dist/routes/ai.js.map +1 -0
- package/dist/routes/analytics.d.ts +9 -0
- package/dist/routes/analytics.d.ts.map +1 -0
- package/dist/routes/analytics.js +192 -0
- package/dist/routes/analytics.js.map +1 -0
- package/dist/routes/endpoints.d.ts +10 -0
- package/dist/routes/endpoints.d.ts.map +1 -0
- package/dist/routes/endpoints.js +255 -0
- package/dist/routes/endpoints.js.map +1 -0
- package/dist/routes/entities.d.ts +15 -0
- package/dist/routes/entities.d.ts.map +1 -0
- package/dist/routes/entities.js +332 -0
- package/dist/routes/entities.js.map +1 -0
- package/dist/routes/entity-api-keys.d.ts +17 -0
- package/dist/routes/entity-api-keys.d.ts.map +1 -0
- package/dist/routes/entity-api-keys.js +142 -0
- package/dist/routes/entity-api-keys.js.map +1 -0
- package/dist/routes/invitations.d.ts +15 -0
- package/dist/routes/invitations.d.ts.map +1 -0
- package/dist/routes/invitations.js +58 -0
- package/dist/routes/invitations.js.map +1 -0
- package/dist/routes/projects.d.ts +9 -0
- package/dist/routes/projects.d.ts.map +1 -0
- package/dist/routes/projects.js +241 -0
- package/dist/routes/projects.js.map +1 -0
- package/dist/routes/providers.d.ts +9 -0
- package/dist/routes/providers.d.ts.map +1 -0
- package/dist/routes/providers.js +89 -0
- package/dist/routes/providers.js.map +1 -0
- package/dist/routes/ratelimits.d.ts +9 -0
- package/dist/routes/ratelimits.d.ts.map +1 -0
- package/dist/routes/ratelimits.js +171 -0
- package/dist/routes/ratelimits.js.map +1 -0
- package/dist/routes/settings.d.ts +9 -0
- package/dist/routes/settings.d.ts.map +1 -0
- package/dist/routes/settings.js +138 -0
- package/dist/routes/settings.js.map +1 -0
- package/dist/routes/storage.d.ts +9 -0
- package/dist/routes/storage.d.ts.map +1 -0
- package/dist/routes/storage.js +180 -0
- package/dist/routes/storage.js.map +1 -0
- package/dist/routes/user-api-keys.d.ts +14 -0
- package/dist/routes/user-api-keys.d.ts.map +1 -0
- package/dist/routes/user-api-keys.js +237 -0
- package/dist/routes/user-api-keys.js.map +1 -0
- package/dist/routes/users.d.ts +7 -0
- package/dist/routes/users.d.ts.map +1 -0
- package/dist/routes/users.js +102 -0
- package/dist/routes/users.js.map +1 -0
- package/dist/schema/init.d.ts +12 -0
- package/dist/schema/init.d.ts.map +1 -0
- package/dist/schema/init.js +404 -0
- package/dist/schema/init.js.map +1 -0
- package/dist/schema/tables.d.ts +2110 -0
- package/dist/schema/tables.d.ts.map +1 -0
- package/dist/schema/tables.js +251 -0
- package/dist/schema/tables.js.map +1 -0
- package/dist/schemas/index.d.ts +671 -0
- package/dist/schemas/index.d.ts.map +1 -0
- package/dist/schemas/index.js +299 -0
- package/dist/schemas/index.js.map +1 -0
- package/dist/service.d.ts +41 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/service.js +70 -0
- package/dist/service.js.map +1 -0
- package/package.json +85 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Shared entity permission helpers
|
|
3
|
+
* @description Builds the entity helpers and the shared
|
|
4
|
+
* `getEntityWithPermission()` check for one product's database, plus the
|
|
5
|
+
* actor helpers every route uses.
|
|
6
|
+
*/
|
|
7
|
+
import { type Entity, type EntityPermissions } from "@sudobility/entity_service";
|
|
8
|
+
import type { Context } from "hono";
|
|
9
|
+
import type { ServiceDb } from "../contracts.js";
|
|
10
|
+
import type { ServiceTables } from "../schema/tables.js";
|
|
11
|
+
/** Successful permission check result */
|
|
12
|
+
interface PermissionSuccess {
|
|
13
|
+
entity: Entity;
|
|
14
|
+
error?: never;
|
|
15
|
+
errorCode?: never;
|
|
16
|
+
}
|
|
17
|
+
/** Failed permission check result */
|
|
18
|
+
interface PermissionFailure {
|
|
19
|
+
entity?: never;
|
|
20
|
+
error: string;
|
|
21
|
+
errorCode: string;
|
|
22
|
+
}
|
|
23
|
+
/** Discriminated union for permission check results */
|
|
24
|
+
export type EntityPermissionResult = PermissionSuccess | PermissionFailure;
|
|
25
|
+
/**
|
|
26
|
+
* Who is making a request.
|
|
27
|
+
*
|
|
28
|
+
* A Firebase token or a personal API key identifies a *user*, whose access is
|
|
29
|
+
* decided by their membership role. An entity API key identifies the *entity*
|
|
30
|
+
* itself -- it carries no membership, so it is authorised by matching the
|
|
31
|
+
* entity it was issued for.
|
|
32
|
+
*/
|
|
33
|
+
export type EntityActor = {
|
|
34
|
+
kind: "user";
|
|
35
|
+
userId: string;
|
|
36
|
+
} | {
|
|
37
|
+
kind: "entity_api_key";
|
|
38
|
+
entityId: string;
|
|
39
|
+
keyId: string;
|
|
40
|
+
};
|
|
41
|
+
/** Build a user actor. Accepts a bare Firebase UID for brevity at call sites. */
|
|
42
|
+
export declare function userActor(userId: string): EntityActor;
|
|
43
|
+
/**
|
|
44
|
+
* Permissions granted to an entity API key over its own entity.
|
|
45
|
+
*
|
|
46
|
+
* Manager-level: it may manage projects, endpoints, provider keys, and storage,
|
|
47
|
+
* but never members or roles. Minting further API keys is blocked separately in
|
|
48
|
+
* the entity API key routes, so a leaked key cannot mint more of itself.
|
|
49
|
+
*/
|
|
50
|
+
export declare const ENTITY_API_KEY_PERMISSIONS: EntityPermissions;
|
|
51
|
+
/**
|
|
52
|
+
* Entity helpers and the permission check, bound to one product's database.
|
|
53
|
+
* Routes read these from the service context rather than a module singleton.
|
|
54
|
+
*/
|
|
55
|
+
export declare function createEntityAccess(opts: {
|
|
56
|
+
db: ServiceDb;
|
|
57
|
+
tables: ServiceTables;
|
|
58
|
+
entityKeyPrefix: string;
|
|
59
|
+
}): {
|
|
60
|
+
entityHelpers: {
|
|
61
|
+
entity: import("@sudobility/entity_service").EntityHelper;
|
|
62
|
+
members: import("@sudobility/entity_service").EntityMemberHelper;
|
|
63
|
+
invitations: import("@sudobility/entity_service").InvitationHelper;
|
|
64
|
+
permissions: import("@sudobility/entity_service").PermissionHelper;
|
|
65
|
+
apiKeys: import("@sudobility/entity_service").ApiKeyHelper | undefined;
|
|
66
|
+
};
|
|
67
|
+
getEntityWithPermission: (entitySlug: string, actor: string | EntityActor, requireEdit?: boolean | keyof EntityPermissions) => Promise<EntityPermissionResult>;
|
|
68
|
+
getActor: typeof getActor;
|
|
69
|
+
getPermissionErrorStatus: typeof getPermissionErrorStatus;
|
|
70
|
+
};
|
|
71
|
+
export type EntityAccess = ReturnType<typeof createEntityAccess>;
|
|
72
|
+
/**
|
|
73
|
+
* Read the acting identity off a Hono context.
|
|
74
|
+
*
|
|
75
|
+
* Routes should pass this to `getEntityWithPermission` rather than a bare
|
|
76
|
+
* `userId`, so requests authenticated with an entity API key are authorised as
|
|
77
|
+
* the entity instead of being checked against a membership that does not exist.
|
|
78
|
+
*
|
|
79
|
+
* @param c - The Hono context, after `firebaseAuthMiddleware` has run
|
|
80
|
+
*/
|
|
81
|
+
export declare function getActor(c: Context): EntityActor;
|
|
82
|
+
/**
|
|
83
|
+
* Determine the appropriate HTTP status code for a permission error.
|
|
84
|
+
*
|
|
85
|
+
* @param errorCode - The error code from `getEntityWithPermission()`
|
|
86
|
+
* @returns 404 for not-found, 403 for access/permission errors
|
|
87
|
+
*/
|
|
88
|
+
export declare function getPermissionErrorStatus(errorCode: string): 400 | 403 | 404;
|
|
89
|
+
export {};
|
|
90
|
+
//# sourceMappingURL=entity-helpers.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"entity-helpers.d.ts","sourceRoot":"","sources":["../../src/lib/entity-helpers.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAKL,KAAK,MAAM,EACX,KAAK,iBAAiB,EACvB,MAAM,4BAA4B,CAAC;AACpC,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,MAAM,CAAC;AACpC,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AACjD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAMzD,yCAAyC;AACzC,UAAU,iBAAiB;IACzB,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,KAAK,CAAC;IACd,SAAS,CAAC,EAAE,KAAK,CAAC;CACnB;AAED,qCAAqC;AACrC,UAAU,iBAAiB;IACzB,MAAM,CAAC,EAAE,KAAK,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,uDAAuD;AACvD,MAAM,MAAM,sBAAsB,GAAG,iBAAiB,GAAG,iBAAiB,CAAC;AAM3E;;;;;;;GAOG;AACH,MAAM,MAAM,WAAW,GACnB;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAChC;IAAE,IAAI,EAAE,gBAAgB,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC;AAEhE,iFAAiF;AACjF,wBAAgB,SAAS,CAAC,MAAM,EAAE,MAAM,GAAG,WAAW,CAErD;AAED;;;;;;GAMG;AACH,eAAO,MAAM,0BAA0B,EAAE,iBACpB,CAAC;AAMtB;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE;IACvC,EAAE,EAAE,SAAS,CAAC;IACd,MAAM,EAAE,aAAa,CAAC;IACtB,eAAe,EAAE,MAAM,CAAC;CACzB;;;;;;;;0CA2Be,MAAM,SACX,MAAM,GAAG,WAAW,gBACd,OAAO,GAAG,MAAM,iBAAiB,KAC7C,OAAO,CAAC,sBAAsB,CAAC;;;EA2DnC;AAED,MAAM,MAAM,YAAY,GAAG,UAAU,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAEjE;;;;;;;;GAQG;AACH,wBAAgB,QAAQ,CAAC,CAAC,EAAE,OAAO,GAAG,WAAW,CAShD;AAED;;;;;GAKG;AACH,wBAAgB,wBAAwB,CAAC,SAAS,EAAE,MAAM,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,CAU3E"}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Shared entity permission helpers
|
|
3
|
+
* @description Builds the entity helpers and the shared
|
|
4
|
+
* `getEntityWithPermission()` check for one product's database, plus the
|
|
5
|
+
* actor helpers every route uses.
|
|
6
|
+
*/
|
|
7
|
+
import { createEntityHelpers, MANAGER_PERMISSIONS, } from "@sudobility/entity_service";
|
|
8
|
+
/** Build a user actor. Accepts a bare Firebase UID for brevity at call sites. */
|
|
9
|
+
export function userActor(userId) {
|
|
10
|
+
return { kind: "user", userId };
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Permissions granted to an entity API key over its own entity.
|
|
14
|
+
*
|
|
15
|
+
* Manager-level: it may manage projects, endpoints, provider keys, and storage,
|
|
16
|
+
* but never members or roles. Minting further API keys is blocked separately in
|
|
17
|
+
* the entity API key routes, so a leaked key cannot mint more of itself.
|
|
18
|
+
*/
|
|
19
|
+
export const ENTITY_API_KEY_PERMISSIONS = MANAGER_PERMISSIONS;
|
|
20
|
+
// =============================================================================
|
|
21
|
+
// Shared Permission Helper
|
|
22
|
+
// =============================================================================
|
|
23
|
+
/**
|
|
24
|
+
* Entity helpers and the permission check, bound to one product's database.
|
|
25
|
+
* Routes read these from the service context rather than a module singleton.
|
|
26
|
+
*/
|
|
27
|
+
export function createEntityAccess(opts) {
|
|
28
|
+
const config = {
|
|
29
|
+
db: opts.db,
|
|
30
|
+
entitiesTable: opts.tables.entities,
|
|
31
|
+
membersTable: opts.tables.entityMembers,
|
|
32
|
+
invitationsTable: opts.tables.entityInvitations,
|
|
33
|
+
apiKeysTable: opts.tables.entityApiKeys,
|
|
34
|
+
usersTable: opts.tables.users,
|
|
35
|
+
keyPrefix: opts.entityKeyPrefix,
|
|
36
|
+
};
|
|
37
|
+
const entityHelpers = createEntityHelpers(config);
|
|
38
|
+
/**
|
|
39
|
+
* Look up an entity by slug and verify the user has appropriate permissions.
|
|
40
|
+
*
|
|
41
|
+
* When `requireEdit` is false, checks view access. When it is `true`, checks
|
|
42
|
+
* if the user can create projects (i.e., has an admin/editor role). Pass a
|
|
43
|
+
* permission name instead to require that specific permission -- for example
|
|
44
|
+
* `"canManageApiKeys"` for routes that write credentials.
|
|
45
|
+
*
|
|
46
|
+
* @param entitySlug - The entity's URL-safe slug
|
|
47
|
+
* @param actor - The requesting actor: a Firebase UID, or an `EntityActor`
|
|
48
|
+
* (use `getActor(c)` so entity API key requests are handled)
|
|
49
|
+
* @param requireEdit - `true` for `canCreateProjects`, or a permission name (default: false)
|
|
50
|
+
* @returns A discriminated union with either `{ entity }` or `{ error, errorCode }`
|
|
51
|
+
*/
|
|
52
|
+
async function getEntityWithPermission(entitySlug, actor, requireEdit = false) {
|
|
53
|
+
const resolved = typeof actor === "string" ? userActor(actor) : actor;
|
|
54
|
+
const entity = await entityHelpers.entity.getEntityBySlug(entitySlug);
|
|
55
|
+
if (!entity) {
|
|
56
|
+
return { error: "Entity not found", errorCode: "ENTITY_NOT_FOUND" };
|
|
57
|
+
}
|
|
58
|
+
const required = requireEdit === true ? "canCreateProjects" : requireEdit;
|
|
59
|
+
// An entity API key authenticates as the entity, so there is no membership to
|
|
60
|
+
// consult: it is authorised exactly for the entity it was issued for.
|
|
61
|
+
if (resolved.kind === "entity_api_key") {
|
|
62
|
+
if (resolved.entityId !== entity.id) {
|
|
63
|
+
return {
|
|
64
|
+
error: "API key does not grant access to this entity",
|
|
65
|
+
errorCode: "ACCESS_DENIED",
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
if (required && !ENTITY_API_KEY_PERMISSIONS[required]) {
|
|
69
|
+
return {
|
|
70
|
+
error: "Insufficient permissions",
|
|
71
|
+
errorCode: "INSUFFICIENT_PERMISSIONS",
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
return { entity };
|
|
75
|
+
}
|
|
76
|
+
if (required) {
|
|
77
|
+
const permissions = await entityHelpers.permissions.getUserPermissions(entity.id, resolved.userId);
|
|
78
|
+
if (!permissions?.[required]) {
|
|
79
|
+
return {
|
|
80
|
+
error: "Insufficient permissions",
|
|
81
|
+
errorCode: "INSUFFICIENT_PERMISSIONS",
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
const canView = await entityHelpers.permissions.canViewEntity(entity.id, resolved.userId);
|
|
87
|
+
if (!canView) {
|
|
88
|
+
return { error: "Access denied", errorCode: "ACCESS_DENIED" };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return { entity };
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
entityHelpers,
|
|
95
|
+
getEntityWithPermission,
|
|
96
|
+
getActor,
|
|
97
|
+
getPermissionErrorStatus,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Read the acting identity off a Hono context.
|
|
102
|
+
*
|
|
103
|
+
* Routes should pass this to `getEntityWithPermission` rather than a bare
|
|
104
|
+
* `userId`, so requests authenticated with an entity API key are authorised as
|
|
105
|
+
* the entity instead of being checked against a membership that does not exist.
|
|
106
|
+
*
|
|
107
|
+
* @param c - The Hono context, after `firebaseAuthMiddleware` has run
|
|
108
|
+
*/
|
|
109
|
+
export function getActor(c) {
|
|
110
|
+
if (c.get("authMethod") === "entity_api_key") {
|
|
111
|
+
return {
|
|
112
|
+
kind: "entity_api_key",
|
|
113
|
+
entityId: c.get("entityApiKeyEntityId"),
|
|
114
|
+
keyId: c.get("entityApiKeyId"),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
return userActor(c.get("userId"));
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Determine the appropriate HTTP status code for a permission error.
|
|
121
|
+
*
|
|
122
|
+
* @param errorCode - The error code from `getEntityWithPermission()`
|
|
123
|
+
* @returns 404 for not-found, 403 for access/permission errors
|
|
124
|
+
*/
|
|
125
|
+
export function getPermissionErrorStatus(errorCode) {
|
|
126
|
+
switch (errorCode) {
|
|
127
|
+
case "ENTITY_NOT_FOUND":
|
|
128
|
+
return 404;
|
|
129
|
+
case "ACCESS_DENIED":
|
|
130
|
+
case "INSUFFICIENT_PERMISSIONS":
|
|
131
|
+
return 403;
|
|
132
|
+
default:
|
|
133
|
+
return 400;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
//# sourceMappingURL=entity-helpers.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"entity-helpers.js","sourceRoot":"","sources":["../../src/lib/entity-helpers.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EACL,mBAAmB,EACnB,mBAAmB,GAKpB,MAAM,4BAA4B,CAAC;AA0CpC,iFAAiF;AACjF,MAAM,UAAU,SAAS,CAAC,MAAc;IACtC,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,CAAC;AAClC,CAAC;AAED;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,0BAA0B,GACrC,mBAAmB,CAAC;AAEtB,gFAAgF;AAChF,2BAA2B;AAC3B,gFAAgF;AAEhF;;;GAGG;AACH,MAAM,UAAU,kBAAkB,CAAC,IAIlC;IACC,MAAM,MAAM,GAAgD;QAC1D,EAAE,EAAE,IAAI,CAAC,EAAS;QAClB,aAAa,EAAE,IAAI,CAAC,MAAM,CAAC,QAAQ;QACnC,YAAY,EAAE,IAAI,CAAC,MAAM,CAAC,aAAa;QACvC,gBAAgB,EAAE,IAAI,CAAC,MAAM,CAAC,iBAAiB;QAC/C,YAAY,EAAE,IAAI,CAAC,MAAM,CAAC,aAAa;QACvC,UAAU,EAAE,IAAI,CAAC,MAAM,CAAC,KAAK;QAC7B,SAAS,EAAE,IAAI,CAAC,eAAe;KAChC,CAAC;IACF,MAAM,aAAa,GAAG,mBAAmB,CAAC,MAAM,CAAC,CAAC;IAElD;;;;;;;;;;;;;OAaG;IACH,KAAK,UAAU,uBAAuB,CACpC,UAAkB,EAClB,KAA2B,EAC3B,cAAiD,KAAK;QAEtD,MAAM,QAAQ,GACZ,OAAO,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC;QAEvD,MAAM,MAAM,GAAG,MAAM,aAAa,CAAC,MAAM,CAAC,eAAe,CAAC,UAAU,CAAC,CAAC;QACtE,IAAI,CAAC,MAAM,EAAE,CAAC;YACZ,OAAO,EAAE,KAAK,EAAE,kBAAkB,EAAE,SAAS,EAAE,kBAAkB,EAAE,CAAC;QACtE,CAAC;QAED,MAAM,QAAQ,GAAG,WAAW,KAAK,IAAI,CAAC,CAAC,CAAC,mBAAmB,CAAC,CAAC,CAAC,WAAW,CAAC;QAE1E,8EAA8E;QAC9E,sEAAsE;QACtE,IAAI,QAAQ,CAAC,IAAI,KAAK,gBAAgB,EAAE,CAAC;YACvC,IAAI,QAAQ,CAAC,QAAQ,KAAK,MAAM,CAAC,EAAE,EAAE,CAAC;gBACpC,OAAO;oBACL,KAAK,EAAE,8CAA8C;oBACrD,SAAS,EAAE,eAAe;iBAC3B,CAAC;YACJ,CAAC;YACD,IAAI,QAAQ,IAAI,CAAC,0BAA0B,CAAC,QAAQ,CAAC,EAAE,CAAC;gBACtD,OAAO;oBACL,KAAK,EAAE,0BAA0B;oBACjC,SAAS,EAAE,0BAA0B;iBACtC,CAAC;YACJ,CAAC;YACD,OAAO,EAAE,MAAM,EAAE,CAAC;QACpB,CAAC;QAED,IAAI,QAAQ,EAAE,CAAC;YACb,MAAM,WAAW,GAAG,MAAM,aAAa,CAAC,WAAW,CAAC,kBAAkB,CACpE,MAAM,CAAC,EAAE,EACT,QAAQ,CAAC,MAAM,CAChB,CAAC;YACF,IAAI,CAAC,WAAW,EAAE,CAAC,QAAQ,CAAC,EAAE,CAAC;gBAC7B,OAAO;oBACL,KAAK,EAAE,0BAA0B;oBACjC,SAAS,EAAE,0BAA0B;iBACtC,CAAC;YACJ,CAAC;QACH,CAAC;aAAM,CAAC;YACN,MAAM,OAAO,GAAG,MAAM,aAAa,CAAC,WAAW,CAAC,aAAa,CAC3D,MAAM,CAAC,EAAE,EACT,QAAQ,CAAC,MAAM,CAChB,CAAC;YACF,IAAI,CAAC,OAAO,EAAE,CAAC;gBACb,OAAO,EAAE,KAAK,EAAE,eAAe,EAAE,SAAS,EAAE,eAAe,EAAE,CAAC;YAChE,CAAC;QACH,CAAC;QAED,OAAO,EAAE,MAAM,EAAE,CAAC;IACpB,CAAC;IAED,OAAO;QACL,aAAa;QACb,uBAAuB;QACvB,QAAQ;QACR,wBAAwB;KACzB,CAAC;AACJ,CAAC;AAID;;;;;;;;GAQG;AACH,MAAM,UAAU,QAAQ,CAAC,CAAU;IACjC,IAAI,CAAC,CAAC,GAAG,CAAC,YAAY,CAAC,KAAK,gBAAgB,EAAE,CAAC;QAC7C,OAAO;YACL,IAAI,EAAE,gBAAgB;YACtB,QAAQ,EAAE,CAAC,CAAC,GAAG,CAAC,sBAAsB,CAAC;YACvC,KAAK,EAAE,CAAC,CAAC,GAAG,CAAC,gBAAgB,CAAC;SAC/B,CAAC;IACJ,CAAC;IACD,OAAO,SAAS,CAAC,CAAC,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC,CAAC;AACpC,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,wBAAwB,CAAC,SAAiB;IACxD,QAAQ,SAAS,EAAE,CAAC;QAClB,KAAK,kBAAkB;YACrB,OAAO,GAAG,CAAC;QACb,KAAK,eAAe,CAAC;QACrB,KAAK,0BAA0B;YAC7B,OAAO,GAAG,CAAC;QACb;YACE,OAAO,GAAG,CAAC;IACf,CAAC;AACH,CAAC"}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convert a floating-point cent amount (what `estimateCost` returns) to integer
|
|
3
|
+
* micro-cents (10^-6 cent). Integer micro-cents are what billing hooks receive:
|
|
4
|
+
* exact to sum, and a sub-cent call is never rounded to nothing.
|
|
5
|
+
*/
|
|
6
|
+
export declare function toMicroCents(cents: number): bigint;
|
|
7
|
+
//# sourceMappingURL=money.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"money.d.ts","sourceRoot":"","sources":["../../src/lib/money.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAElD"}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convert a floating-point cent amount (what `estimateCost` returns) to integer
|
|
3
|
+
* micro-cents (10^-6 cent). Integer micro-cents are what billing hooks receive:
|
|
4
|
+
* exact to sum, and a sub-cent call is never rounded to nothing.
|
|
5
|
+
*/
|
|
6
|
+
export function toMicroCents(cents) {
|
|
7
|
+
return BigInt(Math.round(cents * 1_000_000));
|
|
8
|
+
}
|
|
9
|
+
//# sourceMappingURL=money.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"money.js","sourceRoot":"","sources":["../../src/lib/money.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,MAAM,UAAU,YAAY,CAAC,KAAa;IACxC,OAAO,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,KAAK,GAAG,SAAS,CAAC,CAAC,CAAC;AAC/C,CAAC"}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A project as a caller should see it.
|
|
3
|
+
*
|
|
4
|
+
* `db.select()` returns every column, which for a project includes the
|
|
5
|
+
* ENCRYPTED api key and its IV. Encrypted is not the same as safe to hand out:
|
|
6
|
+
* ciphertext plus IV is strictly more than a caller needs, and identifying a
|
|
7
|
+
* project is what `api_key_prefix` is for. The invocation path reads the real
|
|
8
|
+
* columns from its own query, so nothing depends on them being in a response.
|
|
9
|
+
*
|
|
10
|
+
* Its own module rather than the route file so it can be tested without
|
|
11
|
+
* importing the whole HTTP surface.
|
|
12
|
+
*/
|
|
13
|
+
export declare function publicProject<T extends Record<string, unknown>>(row: T): Omit<T, "encrypted_api_key" | "api_key_iv">;
|
|
14
|
+
//# sourceMappingURL=public-project.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"public-project.d.ts","sourceRoot":"","sources":["../../src/lib/public-project.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,wBAAgB,aAAa,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,GAAG,EAAE,CAAC,+CAGtE"}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A project as a caller should see it.
|
|
3
|
+
*
|
|
4
|
+
* `db.select()` returns every column, which for a project includes the
|
|
5
|
+
* ENCRYPTED api key and its IV. Encrypted is not the same as safe to hand out:
|
|
6
|
+
* ciphertext plus IV is strictly more than a caller needs, and identifying a
|
|
7
|
+
* project is what `api_key_prefix` is for. The invocation path reads the real
|
|
8
|
+
* columns from its own query, so nothing depends on them being in a response.
|
|
9
|
+
*
|
|
10
|
+
* Its own module rather than the route file so it can be tested without
|
|
11
|
+
* importing the whole HTTP surface.
|
|
12
|
+
*/
|
|
13
|
+
export function publicProject(row) {
|
|
14
|
+
const { encrypted_api_key: _e, api_key_iv: _i, ...rest } = row;
|
|
15
|
+
return rest;
|
|
16
|
+
}
|
|
17
|
+
//# sourceMappingURL=public-project.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"public-project.js","sourceRoot":"","sources":["../../src/lib/public-project.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,aAAa,CAAoC,GAAM;IACrE,MAAM,EAAE,iBAAiB,EAAE,EAAE,EAAE,UAAU,EAAE,EAAE,EAAE,GAAG,IAAI,EAAE,GAAG,GAAG,CAAC;IAC/D,OAAO,IAAI,CAAC;AACd,CAAC"}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview In-process cache for user API key lookups.
|
|
3
|
+
*
|
|
4
|
+
* Every request authenticated with a personal API key would otherwise pay a
|
|
5
|
+
* database round-trip before the handler does its own work. Keys change rarely,
|
|
6
|
+
* so the resolved identity is cached for a short window.
|
|
7
|
+
*
|
|
8
|
+
* Only SUCCESSFUL lookups are cached: an unknown key re-checks the database
|
|
9
|
+
* every time, so a key created moments ago works immediately and a bogus key can
|
|
10
|
+
* never be pinned in memory. The short TTL bounds how long a *revoked* key keeps
|
|
11
|
+
* working — deleting or deactivating a key takes effect within
|
|
12
|
+
* `KEY_CACHE_TTL_MS`, and `invalidateUserApiKeyCache` clears it immediately when
|
|
13
|
+
* the revocation goes through this API.
|
|
14
|
+
*/
|
|
15
|
+
import type { Logger, ServiceDb } from "../contracts.js";
|
|
16
|
+
import type { ServiceTables } from "../schema/tables.js";
|
|
17
|
+
export interface ResolvedApiKeyUser {
|
|
18
|
+
/** Firebase UID of the key's owner — used exactly like a token's uid */
|
|
19
|
+
userId: string;
|
|
20
|
+
/** Owner's email, or null when the user record has none */
|
|
21
|
+
userEmail: string | null;
|
|
22
|
+
/** UUID of the API key row itself */
|
|
23
|
+
keyId: string;
|
|
24
|
+
}
|
|
25
|
+
export declare function createUserApiKeyCache(opts: {
|
|
26
|
+
db: ServiceDb;
|
|
27
|
+
tables: Pick<ServiceTables, "userApiKeys" | "users">;
|
|
28
|
+
hashUserApiKey: (key: string) => string;
|
|
29
|
+
logger: Logger;
|
|
30
|
+
}): {
|
|
31
|
+
resolveUserApiKey: (key: string) => Promise<ResolvedApiKeyUser | null>;
|
|
32
|
+
invalidateUserApiKeyCache: (keyHash?: string) => void;
|
|
33
|
+
};
|
|
34
|
+
export type UserApiKeyCache = ReturnType<typeof createUserApiKeyCache>;
|
|
35
|
+
//# sourceMappingURL=user-api-key-cache.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"user-api-key-cache.d.ts","sourceRoot":"","sources":["../../src/lib/user-api-key-cache.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAGH,OAAO,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AACzD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAOzD,MAAM,WAAW,kBAAkB;IACjC,wEAAwE;IACxE,MAAM,EAAE,MAAM,CAAC;IACf,2DAA2D;IAC3D,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,qCAAqC;IACrC,KAAK,EAAE,MAAM,CAAC;CACf;AAOD,wBAAgB,qBAAqB,CAAC,IAAI,EAAE;IAC1C,EAAE,EAAE,SAAS,CAAC;IACd,MAAM,EAAE,IAAI,CAAC,aAAa,EAAE,aAAa,GAAG,OAAO,CAAC,CAAC;IACrD,cAAc,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,MAAM,CAAC;IACxC,MAAM,EAAE,MAAM,CAAC;CAChB;6BAWQ,MAAM,KACV,OAAO,CAAC,kBAAkB,GAAG,IAAI,CAAC;0CA4EQ,MAAM,KAAG,IAAI;EAS3D;AAED,MAAM,MAAM,eAAe,GAAG,UAAU,CAAC,OAAO,qBAAqB,CAAC,CAAC"}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview In-process cache for user API key lookups.
|
|
3
|
+
*
|
|
4
|
+
* Every request authenticated with a personal API key would otherwise pay a
|
|
5
|
+
* database round-trip before the handler does its own work. Keys change rarely,
|
|
6
|
+
* so the resolved identity is cached for a short window.
|
|
7
|
+
*
|
|
8
|
+
* Only SUCCESSFUL lookups are cached: an unknown key re-checks the database
|
|
9
|
+
* every time, so a key created moments ago works immediately and a bogus key can
|
|
10
|
+
* never be pinned in memory. The short TTL bounds how long a *revoked* key keeps
|
|
11
|
+
* working — deleting or deactivating a key takes effect within
|
|
12
|
+
* `KEY_CACHE_TTL_MS`, and `invalidateUserApiKeyCache` clears it immediately when
|
|
13
|
+
* the revocation goes through this API.
|
|
14
|
+
*/
|
|
15
|
+
import { eq, and } from "drizzle-orm";
|
|
16
|
+
const KEY_CACHE_TTL_MS = 60_000;
|
|
17
|
+
/** How stale `last_used_at` may get before we write it again. */
|
|
18
|
+
const LAST_USED_WRITE_INTERVAL_MS = 5 * 60_000;
|
|
19
|
+
export function createUserApiKeyCache(opts) {
|
|
20
|
+
const { db, hashUserApiKey, logger } = opts;
|
|
21
|
+
const { userApiKeys, users } = opts.tables;
|
|
22
|
+
const cache = new Map();
|
|
23
|
+
/**
|
|
24
|
+
* Resolve a plain text user API key to the identity it authenticates.
|
|
25
|
+
* @param key The personal key value from the request
|
|
26
|
+
* @returns The owner's identity, or null when the key is unknown or inactive
|
|
27
|
+
*/
|
|
28
|
+
async function resolveUserApiKey(key) {
|
|
29
|
+
const now = Date.now();
|
|
30
|
+
const hash = hashUserApiKey(key);
|
|
31
|
+
const cached = cache.get(hash);
|
|
32
|
+
if (cached && cached.expiresAt > now) {
|
|
33
|
+
void touchLastUsed(hash, cached, now);
|
|
34
|
+
return {
|
|
35
|
+
userId: cached.userId,
|
|
36
|
+
userEmail: cached.userEmail,
|
|
37
|
+
keyId: cached.keyId,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
const rows = await db
|
|
41
|
+
.select({
|
|
42
|
+
keyId: userApiKeys.uuid,
|
|
43
|
+
userId: userApiKeys.firebase_uid,
|
|
44
|
+
userEmail: users.email,
|
|
45
|
+
})
|
|
46
|
+
.from(userApiKeys)
|
|
47
|
+
.leftJoin(users, eq(users.firebase_uid, userApiKeys.firebase_uid))
|
|
48
|
+
.where(and(eq(userApiKeys.key_hash, hash), eq(userApiKeys.is_active, true)))
|
|
49
|
+
.limit(1);
|
|
50
|
+
const row = rows[0];
|
|
51
|
+
if (!row) {
|
|
52
|
+
cache.delete(hash);
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
const resolved = {
|
|
56
|
+
userId: row.userId,
|
|
57
|
+
userEmail: row.userEmail ?? null,
|
|
58
|
+
keyId: row.keyId,
|
|
59
|
+
};
|
|
60
|
+
cache.set(hash, {
|
|
61
|
+
...resolved,
|
|
62
|
+
expiresAt: now + KEY_CACHE_TTL_MS,
|
|
63
|
+
lastUsedWrittenAt: 0,
|
|
64
|
+
});
|
|
65
|
+
void touchLastUsed(hash, cache.get(hash), now);
|
|
66
|
+
return resolved;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Record that a key was used, at most once per LAST_USED_WRITE_INTERVAL_MS.
|
|
70
|
+
* Fire-and-forget: a failed bookkeeping write must never fail the request.
|
|
71
|
+
*/
|
|
72
|
+
async function touchLastUsed(hash, entry, now) {
|
|
73
|
+
if (now - entry.lastUsedWrittenAt < LAST_USED_WRITE_INTERVAL_MS)
|
|
74
|
+
return;
|
|
75
|
+
entry.lastUsedWrittenAt = now;
|
|
76
|
+
try {
|
|
77
|
+
await db
|
|
78
|
+
.update(userApiKeys)
|
|
79
|
+
.set({ last_used_at: new Date(now) })
|
|
80
|
+
.where(eq(userApiKeys.uuid, entry.keyId));
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
logger.error("Failed to update user API key last_used_at:", error);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Drop a key from the cache so a revocation or rename takes effect at once.
|
|
88
|
+
* @param keyHash The stored SHA-256 hash, or undefined to clear everything
|
|
89
|
+
*/
|
|
90
|
+
function invalidateUserApiKeyCache(keyHash) {
|
|
91
|
+
if (keyHash) {
|
|
92
|
+
cache.delete(keyHash);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
cache.clear();
|
|
96
|
+
}
|
|
97
|
+
return { resolveUserApiKey, invalidateUserApiKeyCache };
|
|
98
|
+
}
|
|
99
|
+
//# sourceMappingURL=user-api-key-cache.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"user-api-key-cache.js","sourceRoot":"","sources":["../../src/lib/user-api-key-cache.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview User API key management
|
|
3
|
+
* @description Generates, hashes, encrypts, and validates personal API keys.
|
|
4
|
+
*
|
|
5
|
+
* Keys use the format `<prefix><base64url-random>` (the prefix is configured
|
|
6
|
+
* per product, e.g. `shyft_`) and are stored three ways:
|
|
7
|
+
* - `key_hash` SHA-256 of the key, unique and indexed. Authentication looks
|
|
8
|
+
* the key up by hash, so no decryption is needed on the hot path.
|
|
9
|
+
* - `encrypted_key` + `encryption_iv` AES-256-CBC ciphertext, so the owner can
|
|
10
|
+
* reveal the key again from the dashboard (same treatment
|
|
11
|
+
* project API keys already get).
|
|
12
|
+
* - `key_prefix` First 14 characters, for display in lists.
|
|
13
|
+
*
|
|
14
|
+
* Distinct from the project API key (`sk_live_...`), which authenticates callers
|
|
15
|
+
* of a published AI endpoint. A user API key authenticates the *owner* against
|
|
16
|
+
* the admin routes, exactly as a Firebase ID token does.
|
|
17
|
+
*/
|
|
18
|
+
import type { Encryption } from "./encryption.js";
|
|
19
|
+
/**
|
|
20
|
+
* Hash a user API key for storage and lookup.
|
|
21
|
+
* SHA-256 is appropriate here (rather than a slow password hash) because the key
|
|
22
|
+
* is 256 bits of random data, not a guessable secret.
|
|
23
|
+
* @param key The plain text API key
|
|
24
|
+
* @returns Lowercase hex digest, 64 characters
|
|
25
|
+
*/
|
|
26
|
+
export declare function hashUserApiKey(key: string): string;
|
|
27
|
+
export declare function createUserApiKeys(opts: {
|
|
28
|
+
prefix: string;
|
|
29
|
+
encryption: Encryption;
|
|
30
|
+
}): {
|
|
31
|
+
prefix: string;
|
|
32
|
+
generateUserApiKey: () => {
|
|
33
|
+
key: string;
|
|
34
|
+
prefix: string;
|
|
35
|
+
};
|
|
36
|
+
hashUserApiKey: typeof hashUserApiKey;
|
|
37
|
+
encryptUserApiKey: (key: string) => {
|
|
38
|
+
encrypted: string;
|
|
39
|
+
iv: string;
|
|
40
|
+
};
|
|
41
|
+
decryptUserApiKey: (encrypted: string, iv: string) => string;
|
|
42
|
+
isUserApiKeyFormat: (value: string) => boolean;
|
|
43
|
+
extractUserApiKeyFromHeaders: (getHeader: (name: string) => string | undefined) => string | null;
|
|
44
|
+
};
|
|
45
|
+
export type UserApiKeys = ReturnType<typeof createUserApiKeys>;
|
|
46
|
+
//# sourceMappingURL=user-api-key.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"user-api-key.d.ts","sourceRoot":"","sources":["../../src/lib/user-api-key.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAGH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAQlD;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAElD;AAED,wBAAgB,iBAAiB,CAAC,IAAI,EAAE;IACtC,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,UAAU,CAAC;CACxB;;8BAQgC;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE;;6BAa9B,MAAM,KAAG;QACvC,SAAS,EAAE,MAAM,CAAC;QAClB,EAAE,EAAE,MAAM,CAAC;KACZ;mCASqC,MAAM,MAAM,MAAM,KAAG,MAAM;gCAQ9B,MAAM,KAAG,OAAO;8CAkBtC,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,GAAG,SAAS,KAC9C,MAAM,GAAG,IAAI;EAsBjB;AAED,MAAM,MAAM,WAAW,GAAG,UAAU,CAAC,OAAO,iBAAiB,CAAC,CAAC"}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview User API key management
|
|
3
|
+
* @description Generates, hashes, encrypts, and validates personal API keys.
|
|
4
|
+
*
|
|
5
|
+
* Keys use the format `<prefix><base64url-random>` (the prefix is configured
|
|
6
|
+
* per product, e.g. `shyft_`) and are stored three ways:
|
|
7
|
+
* - `key_hash` SHA-256 of the key, unique and indexed. Authentication looks
|
|
8
|
+
* the key up by hash, so no decryption is needed on the hot path.
|
|
9
|
+
* - `encrypted_key` + `encryption_iv` AES-256-CBC ciphertext, so the owner can
|
|
10
|
+
* reveal the key again from the dashboard (same treatment
|
|
11
|
+
* project API keys already get).
|
|
12
|
+
* - `key_prefix` First 14 characters, for display in lists.
|
|
13
|
+
*
|
|
14
|
+
* Distinct from the project API key (`sk_live_...`), which authenticates callers
|
|
15
|
+
* of a published AI endpoint. A user API key authenticates the *owner* against
|
|
16
|
+
* the admin routes, exactly as a Firebase ID token does.
|
|
17
|
+
*/
|
|
18
|
+
import { createHash, randomBytes } from "crypto";
|
|
19
|
+
/** Random bytes of entropy (32 bytes = 256 bits). */
|
|
20
|
+
const KEY_BYTES_LENGTH = 32;
|
|
21
|
+
/** Characters shown in listings, e.g. "shyft_ab12cd". */
|
|
22
|
+
const DISPLAY_PREFIX_LENGTH = 14;
|
|
23
|
+
/**
|
|
24
|
+
* Hash a user API key for storage and lookup.
|
|
25
|
+
* SHA-256 is appropriate here (rather than a slow password hash) because the key
|
|
26
|
+
* is 256 bits of random data, not a guessable secret.
|
|
27
|
+
* @param key The plain text API key
|
|
28
|
+
* @returns Lowercase hex digest, 64 characters
|
|
29
|
+
*/
|
|
30
|
+
export function hashUserApiKey(key) {
|
|
31
|
+
return createHash("sha256").update(key, "utf8").digest("hex");
|
|
32
|
+
}
|
|
33
|
+
export function createUserApiKeys(opts) {
|
|
34
|
+
const USER_API_KEY_PREFIX = opts.prefix;
|
|
35
|
+
const { encryptApiKey, decryptApiKey } = opts.encryption;
|
|
36
|
+
/**
|
|
37
|
+
* Generate a new user API key.
|
|
38
|
+
* @returns The full key and the prefix stored for display
|
|
39
|
+
*/
|
|
40
|
+
function generateUserApiKey() {
|
|
41
|
+
const randomPart = randomBytes(KEY_BYTES_LENGTH)
|
|
42
|
+
.toString("base64url")
|
|
43
|
+
.replace(/=/g, "");
|
|
44
|
+
const key = `${USER_API_KEY_PREFIX}${randomPart}`;
|
|
45
|
+
return { key, prefix: key.substring(0, DISPLAY_PREFIX_LENGTH) };
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Encrypt a user API key for at-rest storage.
|
|
49
|
+
* @param key The plain text API key
|
|
50
|
+
*/
|
|
51
|
+
function encryptUserApiKey(key) {
|
|
52
|
+
return encryptApiKey(key);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Decrypt a stored user API key so its owner can copy it again.
|
|
56
|
+
* @param encrypted Hex ciphertext
|
|
57
|
+
* @param iv Hex initialization vector
|
|
58
|
+
*/
|
|
59
|
+
function decryptUserApiKey(encrypted, iv) {
|
|
60
|
+
return decryptApiKey(encrypted, iv);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Check whether a string looks like a user API key.
|
|
64
|
+
* Used to route an incoming credential to key auth instead of Firebase auth.
|
|
65
|
+
*/
|
|
66
|
+
function isUserApiKeyFormat(value) {
|
|
67
|
+
return (value.startsWith(USER_API_KEY_PREFIX) &&
|
|
68
|
+
value.length > USER_API_KEY_PREFIX.length);
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Extract a personal API key from request headers.
|
|
72
|
+
*
|
|
73
|
+
* Accepts `X-API-Key: <prefix>...` (preferred, unambiguous) and
|
|
74
|
+
* `Authorization: Bearer <prefix>...`. Anything without the prefix is left
|
|
75
|
+
* alone so it can be verified as a Firebase ID token instead.
|
|
76
|
+
*
|
|
77
|
+
* @param getHeader Reads a request header by name, case-insensitively
|
|
78
|
+
* @returns The key, or null when the request carries none
|
|
79
|
+
*/
|
|
80
|
+
function extractUserApiKeyFromHeaders(getHeader) {
|
|
81
|
+
const headerKey = getHeader("X-API-Key");
|
|
82
|
+
if (headerKey && isUserApiKeyFormat(headerKey))
|
|
83
|
+
return headerKey;
|
|
84
|
+
const authHeader = getHeader("Authorization");
|
|
85
|
+
if (authHeader) {
|
|
86
|
+
const [type, token] = authHeader.split(" ");
|
|
87
|
+
if (type === "Bearer" && token && isUserApiKeyFormat(token))
|
|
88
|
+
return token;
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
prefix: USER_API_KEY_PREFIX,
|
|
94
|
+
generateUserApiKey,
|
|
95
|
+
hashUserApiKey,
|
|
96
|
+
encryptUserApiKey,
|
|
97
|
+
decryptUserApiKey,
|
|
98
|
+
isUserApiKeyFormat,
|
|
99
|
+
extractUserApiKeyFromHeaders,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
//# sourceMappingURL=user-api-key.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"user-api-key.js","sourceRoot":"","sources":["../../src/lib/user-api-key.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,QAAQ,CAAC;AAGjD,qDAAqD;AACrD,MAAM,gBAAgB,GAAG,EAAE,CAAC;AAE5B,yDAAyD;AACzD,MAAM,qBAAqB,GAAG,EAAE,CAAC;AAEjC;;;;;;GAMG;AACH,MAAM,UAAU,cAAc,CAAC,GAAW;IACxC,OAAO,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,GAAG,EAAE,MAAM,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;AAChE,CAAC;AAED,MAAM,UAAU,iBAAiB,CAAC,IAGjC;IACC,MAAM,mBAAmB,GAAG,IAAI,CAAC,MAAM,CAAC;IACxC,MAAM,EAAE,aAAa,EAAE,aAAa,EAAE,GAAG,IAAI,CAAC,UAAU,CAAC;IAEzD;;;OAGG;IACH,SAAS,kBAAkB;QACzB,MAAM,UAAU,GAAG,WAAW,CAAC,gBAAgB,CAAC;aAC7C,QAAQ,CAAC,WAAW,CAAC;aACrB,OAAO,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC;QAErB,MAAM,GAAG,GAAG,GAAG,mBAAmB,GAAG,UAAU,EAAE,CAAC;QAClD,OAAO,EAAE,GAAG,EAAE,MAAM,EAAE,GAAG,CAAC,SAAS,CAAC,CAAC,EAAE,qBAAqB,CAAC,EAAE,CAAC;IAClE,CAAC;IAED;;;OAGG;IACH,SAAS,iBAAiB,CAAC,GAAW;QAIpC,OAAO,aAAa,CAAC,GAAG,CAAC,CAAC;IAC5B,CAAC;IAED;;;;OAIG;IACH,SAAS,iBAAiB,CAAC,SAAiB,EAAE,EAAU;QACtD,OAAO,aAAa,CAAC,SAAS,EAAE,EAAE,CAAC,CAAC;IACtC,CAAC;IAED;;;OAGG;IACH,SAAS,kBAAkB,CAAC,KAAa;QACvC,OAAO,CACL,KAAK,CAAC,UAAU,CAAC,mBAAmB,CAAC;YACrC,KAAK,CAAC,MAAM,GAAG,mBAAmB,CAAC,MAAM,CAC1C,CAAC;IACJ,CAAC;IAED;;;;;;;;;OASG;IACH,SAAS,4BAA4B,CACnC,SAA+C;QAE/C,MAAM,SAAS,GAAG,SAAS,CAAC,WAAW,CAAC,CAAC;QACzC,IAAI,SAAS,IAAI,kBAAkB,CAAC,SAAS,CAAC;YAAE,OAAO,SAAS,CAAC;QAEjE,MAAM,UAAU,GAAG,SAAS,CAAC,eAAe,CAAC,CAAC;QAC9C,IAAI,UAAU,EAAE,CAAC;YACf,MAAM,CAAC,IAAI,EAAE,KAAK,CAAC,GAAG,UAAU,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;YAC5C,IAAI,IAAI,KAAK,QAAQ,IAAI,KAAK,IAAI,kBAAkB,CAAC,KAAK,CAAC;gBAAE,OAAO,KAAK,CAAC;QAC5E,CAAC;QAED,OAAO,IAAI,CAAC;IACd,CAAC;IAED,OAAO;QACL,MAAM,EAAE,mBAAmB;QAC3B,kBAAkB;QAClB,cAAc;QACd,iBAAiB;QACjB,iBAAiB;QACjB,kBAAkB;QAClB,4BAA4B;KAC7B,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Firebase authentication middleware
|
|
3
|
+
*
|
|
4
|
+
* Accepts a personal API key, an entity API key, or a Firebase ID token. Key
|
|
5
|
+
* prefixes, auth functions, and storage all come from the service context.
|
|
6
|
+
*/
|
|
7
|
+
import type { MiddlewareHandler } from "hono";
|
|
8
|
+
import type { DecodedIdToken } from "firebase-admin/auth";
|
|
9
|
+
import type { ServiceContext } from "../context.js";
|
|
10
|
+
/**
|
|
11
|
+
* Augment Hono's ContextVariableMap for type-safe context access.
|
|
12
|
+
*/
|
|
13
|
+
declare module "hono" {
|
|
14
|
+
interface ContextVariableMap {
|
|
15
|
+
firebaseUser: DecodedIdToken;
|
|
16
|
+
userId: string;
|
|
17
|
+
userEmail: string | null;
|
|
18
|
+
siteAdmin: boolean;
|
|
19
|
+
/**
|
|
20
|
+
* How the caller authenticated. Neither key method has a firebaseUser.
|
|
21
|
+
* "api_key" is a personal key (acts as a user); "entity_api_key" is an
|
|
22
|
+
* entity key (acts as the entity itself).
|
|
23
|
+
*/
|
|
24
|
+
authMethod: "firebase" | "api_key" | "entity_api_key";
|
|
25
|
+
/** UUID of the user API key used, when authMethod is "api_key" */
|
|
26
|
+
apiKeyId: string;
|
|
27
|
+
/** UUID of the entity API key used, when authMethod is "entity_api_key" */
|
|
28
|
+
entityApiKeyId: string;
|
|
29
|
+
/** Entity the request acts as, when authMethod is "entity_api_key" */
|
|
30
|
+
entityApiKeyEntityId: string;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export declare function createFirebaseAuthMiddleware(ctx: ServiceContext): MiddlewareHandler;
|
|
34
|
+
//# sourceMappingURL=firebaseAuth.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"firebaseAuth.d.ts","sourceRoot":"","sources":["../../src/middleware/firebaseAuth.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAW,iBAAiB,EAAQ,MAAM,MAAM,CAAC;AAC7D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAG1D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAEpD;;GAEG;AACH,OAAO,QAAQ,MAAM,CAAC;IACpB,UAAU,kBAAkB;QAC1B,YAAY,EAAE,cAAc,CAAC;QAC7B,MAAM,EAAE,MAAM,CAAC;QACf,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;QACzB,SAAS,EAAE,OAAO,CAAC;QACnB;;;;WAIG;QACH,UAAU,EAAE,UAAU,GAAG,SAAS,GAAG,gBAAgB,CAAC;QACtD,kEAAkE;QAClE,QAAQ,EAAE,MAAM,CAAC;QACjB,2EAA2E;QAC3E,cAAc,EAAE,MAAM,CAAC;QACvB,sEAAsE;QACtE,oBAAoB,EAAE,MAAM,CAAC;KAC9B;CACF;AAYD,wBAAgB,4BAA4B,CAC1C,GAAG,EAAE,cAAc,GAClB,iBAAiB,CA8JnB"}
|