@tumbaland/backend-core 1.29.0 → 1.31.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/dist/apiKeys/ApiKey.d.ts +51 -0
- package/dist/apiKeys/ApiKey.d.ts.map +1 -0
- package/dist/apiKeys/ApiKey.js +63 -0
- package/dist/apiKeys/ApiKey.js.map +1 -0
- package/dist/apiKeys/crypto.d.ts +61 -0
- package/dist/apiKeys/crypto.d.ts.map +1 -0
- package/dist/apiKeys/crypto.js +132 -0
- package/dist/apiKeys/crypto.js.map +1 -0
- package/dist/apiKeys/index.d.ts +10 -0
- package/dist/apiKeys/index.d.ts.map +1 -0
- package/dist/apiKeys/index.js +25 -0
- package/dist/apiKeys/index.js.map +1 -0
- package/dist/apiKeys/middleware.d.ts +54 -0
- package/dist/apiKeys/middleware.d.ts.map +1 -0
- package/dist/apiKeys/middleware.js +174 -0
- package/dist/apiKeys/middleware.js.map +1 -0
- package/dist/apiKeys/service.d.ts +43 -0
- package/dist/apiKeys/service.d.ts.map +1 -0
- package/dist/apiKeys/service.js +122 -0
- package/dist/apiKeys/service.js.map +1 -0
- package/dist/apiKeys/types.d.ts +50 -0
- package/dist/apiKeys/types.d.ts.map +1 -0
- package/dist/apiKeys/types.js +24 -0
- package/dist/apiKeys/types.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/apiKeys/ApiKey.ts +75 -0
- package/src/apiKeys/crypto.test.ts +161 -0
- package/src/apiKeys/crypto.ts +163 -0
- package/src/apiKeys/index.ts +21 -0
- package/src/apiKeys/middleware.test.ts +254 -0
- package/src/apiKeys/middleware.ts +206 -0
- package/src/apiKeys/service.ts +149 -0
- package/src/apiKeys/types.ts +69 -0
- package/src/index.ts +3 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { ApiKey, IApiKey } from './ApiKey';
|
|
2
|
+
import {
|
|
3
|
+
decryptSecret,
|
|
4
|
+
displayPrefix,
|
|
5
|
+
encryptSecret,
|
|
6
|
+
generateKey,
|
|
7
|
+
parseKey,
|
|
8
|
+
secretMatches
|
|
9
|
+
} from './crypto';
|
|
10
|
+
import type { ApiKeyScope, ApiKeySummary, ApiKeyVerification } from './types';
|
|
11
|
+
|
|
12
|
+
export interface CreateApiKeyInput {
|
|
13
|
+
userId: string;
|
|
14
|
+
userEmail: string;
|
|
15
|
+
userName: string;
|
|
16
|
+
name: string;
|
|
17
|
+
scopes: ApiKeyScope[];
|
|
18
|
+
/** null pins the key to the user's own non-group data */
|
|
19
|
+
groupId?: string | null;
|
|
20
|
+
expiresAt?: Date | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface CreatedApiKey {
|
|
24
|
+
summary: ApiKeySummary;
|
|
25
|
+
/** the full token — the only time it is returned without an explicit reveal */
|
|
26
|
+
token: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const toSummary = (key: IApiKey): ApiKeySummary => ({
|
|
30
|
+
id: String(key._id),
|
|
31
|
+
name: key.name,
|
|
32
|
+
prefix: displayPrefix(key.keyId),
|
|
33
|
+
scopes: key.scopes,
|
|
34
|
+
groupId: key.groupId ?? null,
|
|
35
|
+
createdAt: key.createdAt.toISOString(),
|
|
36
|
+
lastUsedAt: key.lastUsedAt?.toISOString() ?? null,
|
|
37
|
+
expiresAt: key.expiresAt?.toISOString() ?? null,
|
|
38
|
+
revokedAt: key.revokedAt?.toISOString() ?? null
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
export const createApiKey = async (input: CreateApiKeyInput): Promise<CreatedApiKey> => {
|
|
42
|
+
const { token, id, hash } = generateKey();
|
|
43
|
+
const sealed = encryptSecret(token);
|
|
44
|
+
|
|
45
|
+
const key = await ApiKey.create({
|
|
46
|
+
userId: input.userId,
|
|
47
|
+
userEmail: input.userEmail,
|
|
48
|
+
userName: input.userName,
|
|
49
|
+
name: input.name,
|
|
50
|
+
keyId: id,
|
|
51
|
+
hash,
|
|
52
|
+
sealedCiphertext: sealed.ciphertext,
|
|
53
|
+
sealedIv: sealed.iv,
|
|
54
|
+
sealedTag: sealed.tag,
|
|
55
|
+
scopes: input.scopes,
|
|
56
|
+
groupId: input.groupId ?? null,
|
|
57
|
+
expiresAt: input.expiresAt ?? undefined
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
return { summary: toSummary(key), token };
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export const listApiKeys = async (userId: string): Promise<ApiKeySummary[]> => {
|
|
64
|
+
const keys = await ApiKey.find({ userId }).sort({ createdAt: -1 });
|
|
65
|
+
return keys.map(toSummary);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Read a key back in the clear.
|
|
70
|
+
*
|
|
71
|
+
* Scoped to the owner by query rather than by a check afterwards, so there is no
|
|
72
|
+
* path where a mismatched userId still reaches the decrypt. Revoked keys are
|
|
73
|
+
* still revealable — the user may need to see which key they just turned off.
|
|
74
|
+
*/
|
|
75
|
+
export const revealApiKey = async (userId: string, id: string): Promise<string | null> => {
|
|
76
|
+
const key = await ApiKey.findOne({ _id: id, userId });
|
|
77
|
+
if (!key) return null;
|
|
78
|
+
|
|
79
|
+
const token = decryptSecret({
|
|
80
|
+
ciphertext: key.sealedCiphertext,
|
|
81
|
+
iv: key.sealedIv,
|
|
82
|
+
tag: key.sealedTag
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
key.revealCount += 1;
|
|
86
|
+
key.lastRevealedAt = new Date();
|
|
87
|
+
await key.save();
|
|
88
|
+
|
|
89
|
+
return token;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/** Revoking is a tombstone, not a delete: the audit trail outlives the key. */
|
|
93
|
+
export const revokeApiKey = async (userId: string, id: string): Promise<ApiKeySummary | null> => {
|
|
94
|
+
const key = await ApiKey.findOne({ _id: id, userId });
|
|
95
|
+
if (!key) return null;
|
|
96
|
+
|
|
97
|
+
if (!key.revokedAt) {
|
|
98
|
+
key.revokedAt = new Date();
|
|
99
|
+
await key.save();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return toSummary(key);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export const deleteApiKey = async (userId: string, id: string): Promise<boolean> => {
|
|
106
|
+
const { deletedCount } = await ApiKey.deleteOne({ _id: id, userId });
|
|
107
|
+
return deletedCount > 0;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Check a presented token.
|
|
112
|
+
*
|
|
113
|
+
* Every failure returns the same shape and the caller answers all of them with
|
|
114
|
+
* one message: distinguishing "no such key" from "wrong secret" to the client
|
|
115
|
+
* would confirm which half of a guess was right. The `rejection` field exists
|
|
116
|
+
* for the server's own logs.
|
|
117
|
+
*
|
|
118
|
+
* `lastUsedAt` is written on the side and never awaited — it is a convenience
|
|
119
|
+
* for the listing UI, and making every authenticated request wait on a write to
|
|
120
|
+
* maintain it would be a poor trade.
|
|
121
|
+
*/
|
|
122
|
+
export const verifyApiKey = async (token: unknown): Promise<ApiKeyVerification> => {
|
|
123
|
+
const parsed = parseKey(token);
|
|
124
|
+
if (!parsed) return { ok: false, rejection: 'malformed' };
|
|
125
|
+
|
|
126
|
+
const key = await ApiKey.findOne({ keyId: parsed.id });
|
|
127
|
+
if (!key) return { ok: false, rejection: 'unknown' };
|
|
128
|
+
if (key.revokedAt) return { ok: false, rejection: 'revoked' };
|
|
129
|
+
if (key.expiresAt && key.expiresAt.getTime() <= Date.now()) {
|
|
130
|
+
return { ok: false, rejection: 'expired' };
|
|
131
|
+
}
|
|
132
|
+
if (!secretMatches(parsed.secret, key.hash)) {
|
|
133
|
+
return { ok: false, rejection: 'bad-secret' };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
void ApiKey.updateOne({ _id: key._id }, { $set: { lastUsedAt: new Date() } }).catch(() => {
|
|
137
|
+
// A missed usage timestamp must never fail an otherwise valid request.
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
ok: true,
|
|
142
|
+
userId: key.userId,
|
|
143
|
+
userEmail: key.userEmail,
|
|
144
|
+
userName: key.userName,
|
|
145
|
+
keyId: String(key._id),
|
|
146
|
+
scopes: key.scopes,
|
|
147
|
+
groupId: key.groupId ?? null
|
|
148
|
+
};
|
|
149
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API keys let a non-browser client — an MCP server driving Claude or ChatGPT,
|
|
3
|
+
* a script, a cron job — act as a user without a Google sign-in flow.
|
|
4
|
+
*
|
|
5
|
+
* They are deliberately narrower than a session: a session is a person at a
|
|
6
|
+
* keyboard who can see what they are doing, while a key is handed to software
|
|
7
|
+
* that acts on its own. So a key carries explicit scopes, and it pins the tenant
|
|
8
|
+
* it writes to rather than choosing one per request the way the UI does.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Everything a key may be granted. Read and write are separate on purpose. */
|
|
12
|
+
export const API_KEY_SCOPES = [
|
|
13
|
+
'relationship:read',
|
|
14
|
+
'relationship:write',
|
|
15
|
+
'album:read',
|
|
16
|
+
'album:write',
|
|
17
|
+
'finance:read',
|
|
18
|
+
'finance:write'
|
|
19
|
+
] as const;
|
|
20
|
+
|
|
21
|
+
export type ApiKeyScope = (typeof API_KEY_SCOPES)[number];
|
|
22
|
+
|
|
23
|
+
export const isApiKeyScope = (value: unknown): value is ApiKeyScope =>
|
|
24
|
+
typeof value === 'string' && (API_KEY_SCOPES as readonly string[]).includes(value);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The tenant a key acts in, fixed when the key is created.
|
|
28
|
+
*
|
|
29
|
+
* The web app picks this per request from a tenant selector, but an agent has no
|
|
30
|
+
* such UI and no way to know it guessed wrong — so the choice is made once, by a
|
|
31
|
+
* person, and the key cannot escape it. `groupId: null` means the user's own
|
|
32
|
+
* non-group data.
|
|
33
|
+
*/
|
|
34
|
+
export interface ApiKeyTenant {
|
|
35
|
+
groupId: string | null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A key as the API hands it back — never including the secret. */
|
|
39
|
+
export interface ApiKeySummary {
|
|
40
|
+
id: string;
|
|
41
|
+
name: string;
|
|
42
|
+
/** the public half, shown in listings so a key is identifiable at a glance */
|
|
43
|
+
prefix: string;
|
|
44
|
+
scopes: ApiKeyScope[];
|
|
45
|
+
groupId: string | null;
|
|
46
|
+
createdAt: string;
|
|
47
|
+
lastUsedAt: string | null;
|
|
48
|
+
expiresAt: string | null;
|
|
49
|
+
revokedAt: string | null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Why a presented key was refused. Kept out of the HTTP response — see the middleware. */
|
|
53
|
+
export type ApiKeyRejection =
|
|
54
|
+
| 'malformed'
|
|
55
|
+
| 'unknown'
|
|
56
|
+
| 'revoked'
|
|
57
|
+
| 'expired'
|
|
58
|
+
| 'bad-secret';
|
|
59
|
+
|
|
60
|
+
export interface ApiKeyVerification {
|
|
61
|
+
ok: boolean;
|
|
62
|
+
rejection?: ApiKeyRejection;
|
|
63
|
+
userId?: string;
|
|
64
|
+
userEmail?: string;
|
|
65
|
+
userName?: string;
|
|
66
|
+
keyId?: string;
|
|
67
|
+
scopes?: ApiKeyScope[];
|
|
68
|
+
groupId?: string | null;
|
|
69
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -30,6 +30,9 @@ export * from './entitlements';
|
|
|
30
30
|
// Group ownership (the quota subject for anything pooled)
|
|
31
31
|
export * from './groups';
|
|
32
32
|
|
|
33
|
+
// API keys (non-browser clients: MCP servers, scripts, agents)
|
|
34
|
+
export * from './apiKeys';
|
|
35
|
+
|
|
33
36
|
// Middleware
|
|
34
37
|
export { authenticateToken, optionalAuth } from './middleware/authMiddleware';
|
|
35
38
|
export {
|