@cosmicdrift/kumiko-bundled-features 0.113.1 → 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/package.json +6 -8
- package/src/auth-email-password/__tests__/account-lockout-no-redis.integration.test.ts +3 -3
- package/src/auth-email-password/__tests__/account-lockout.integration.test.ts +3 -3
- package/src/auth-email-password/__tests__/auth-claims.integration.test.ts +10 -9
- package/src/auth-email-password/__tests__/auth.integration.test.ts +5 -5
- package/src/auth-email-password/__tests__/email-verification.integration.test.ts +4 -7
- package/src/auth-email-password/__tests__/identity-v3-login.integration.test.ts +3 -3
- package/src/auth-email-password/__tests__/password-reset.integration.test.ts +4 -3
- package/src/auth-email-password/__tests__/public-routes-rate-limit.integration.test.ts +3 -3
- package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +3 -3
- package/src/auth-email-password/constants.ts +0 -4
- package/src/auth-email-password/feature.ts +4 -8
- package/src/auth-email-password/handlers/change-password.write.ts +11 -11
- package/src/auth-email-password/handlers/confirm-token-flow.ts +104 -33
- package/src/auth-email-password/handlers/login.write.ts +1 -4
- package/src/auth-email-password/password-hashing.ts +0 -11
- package/src/auth-email-password/stream-tenant.ts +31 -0
- package/src/auth-email-password/web/auth-form-primitives.tsx +1 -18
- package/src/auth-email-password/web/auth-gate.tsx +4 -3
- package/src/auth-email-password/web/invite-accept-screen.tsx +2 -2
- package/src/auth-email-password/web/reset-password-screen.tsx +11 -6
- package/src/auth-email-password/web/signup-complete-screen.tsx +5 -5
- package/src/auth-email-password/web/user-menu.tsx +1 -3
- package/src/auth-email-password/web/verify-email-screen.tsx +7 -2
- package/src/config/__tests__/config.integration.test.ts +8 -7
- package/src/config/__tests__/deserialize-value.test.ts +6 -3
- package/src/config/__tests__/inherited-redaction.integration.test.ts +3 -4
- package/src/config/feature.ts +8 -14
- package/src/config/handlers/set.write.ts +2 -2
- package/src/config/index.ts +3 -4
- package/src/config/resolver.ts +10 -34
- package/src/custom-fields/__tests__/custom-fields.integration.test.ts +0 -65
- package/src/custom-fields/constants.ts +0 -1
- package/src/custom-fields/events.ts +0 -6
- package/src/custom-fields/handlers/delete-system-field.write.ts +1 -2
- package/src/custom-fields/handlers/delete-tenant-field.write.ts +1 -2
- package/src/custom-fields/handlers/set-custom-field.write.ts +1 -1
- package/src/custom-fields/handlers/update-tenant-field.write.ts +0 -10
- package/src/custom-fields/wire-for-entity.ts +77 -116
- package/src/data-retention/__tests__/parse-override.test.ts +13 -13
- package/src/file-foundation/__tests__/file-foundation.integration.test.ts +3 -4
- package/src/folders/__tests__/folders.integration.test.ts +0 -3
- package/src/folders/web/__tests__/tree.test.ts +0 -8
- package/src/folders/web/folder-manager.tsx +3 -1
- package/src/folders/web/i18n.ts +2 -0
- package/src/folders/web/tree.ts +2 -13
- package/src/folders-user-data/__tests__/hooks.integration.test.ts +0 -1
- package/src/folders-user-data/index.ts +4 -4
- package/src/jobs/__tests__/job-system-user.integration.test.ts +8 -4
- package/src/jobs/handlers/projection-rebuild.job.ts +4 -14
- package/src/ledger/__tests__/ledger.integration.test.ts +3 -8
- package/src/ledger/handlers/confirm-schedule-period.write.ts +4 -17
- package/src/ledger/recurring.ts +8 -17
- package/src/ledger/web/index.ts +2 -3
- package/src/legal-pages/feature.ts +3 -1
- package/src/mail-foundation/__tests__/mail-foundation.integration.test.ts +3 -4
- package/src/managed-pages/__tests__/branding-coerce.test.ts +6 -2
- package/src/managed-pages/feature.ts +3 -1
- package/src/readiness/__tests__/readiness.integration.test.ts +3 -4
- package/src/secrets/handlers/rotate.job.ts +98 -77
- package/src/secrets/secrets-context.ts +38 -7
- package/src/secrets/table.ts +7 -6
- package/src/sessions/__tests__/password-auto-revoke.integration.test.ts +4 -6
- package/src/sessions/__tests__/sessions.integration.test.ts +3 -3
- package/src/sessions/feature.ts +18 -51
- package/src/sessions/index.ts +2 -2
- package/src/subscription-stripe/__tests__/runtime.test.ts +24 -14
- package/src/subscription-stripe/__tests__/stripe-foundation.integration.test.ts +5 -6
- package/src/tags/__tests__/tags.integration.test.ts +1 -12
- package/src/tags/entity.ts +1 -6
- package/src/tags/schemas.ts +2 -9
- package/src/tags/web/tag-manager.tsx +1 -1
- package/src/template-resolver/testing.ts +1 -2
- package/src/tenant/__tests__/tenant.integration.test.ts +4 -8
- package/src/tier-engine/__tests__/tier-engine.integration.test.ts +4 -7
- package/src/user/__tests__/user.integration.test.ts +0 -26
- package/src/user/handlers/find-for-auth.query.ts +5 -7
- package/src/user/index.ts +0 -4
- package/src/user-data-rights/__tests__/download.integration.test.ts +4 -7
- package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +3 -3
- package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +3 -3
- package/src/user-data-rights/feature.ts +3 -1
- package/src/user-data-rights/handlers/download-by-job.query.ts +6 -4
- package/src/user-data-rights/lib/update-user-lifecycle.ts +24 -8
- package/src/user-data-rights/run-forget-cleanup.ts +0 -1
- package/src/user-data-rights/run-user-export.ts +4 -43
- package/src/user-data-rights/web/__tests__/public-deletion-gate.test.tsx +1 -7
- package/src/user-data-rights/web/client-plugin.tsx +1 -6
- package/src/user-data-rights/web/i18n.ts +10 -0
- package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +9 -31
- package/src/user-profile/__tests__/profile-screen.test.tsx +0 -4
- package/src/user-profile/handlers/change-email.write.ts +8 -11
- package/src/user-profile/web/profile-screen.tsx +1 -12
- package/src/auth-email-password/__tests__/feature-options.test.ts +0 -33
- package/src/auth-email-password/password-hashing.test.ts +0 -35
- package/src/auth-email-password/web/__tests__/use-url-token.test.tsx +0 -29
- package/src/config/__tests__/encrypted-legacy-migration.integration.test.ts +0 -179
- package/src/config/handlers/reencrypt.job.ts +0 -197
- package/src/personal-access-tokens/__tests__/pat.integration.test.ts +0 -204
- package/src/personal-access-tokens/__tests__/scopes.test.ts +0 -42
- package/src/personal-access-tokens/constants.ts +0 -33
- package/src/personal-access-tokens/feature.ts +0 -78
- package/src/personal-access-tokens/handlers/available-scopes.query.ts +0 -21
- package/src/personal-access-tokens/handlers/create.write.ts +0 -41
- package/src/personal-access-tokens/handlers/list.query.ts +0 -47
- package/src/personal-access-tokens/handlers/revoke.write.ts +0 -37
- package/src/personal-access-tokens/hash.ts +0 -17
- package/src/personal-access-tokens/index.ts +0 -45
- package/src/personal-access-tokens/resolver.ts +0 -58
- package/src/personal-access-tokens/roles.ts +0 -29
- package/src/personal-access-tokens/schema/api-token.ts +0 -56
- package/src/personal-access-tokens/scopes.ts +0 -43
- package/src/personal-access-tokens/web/client-plugin.tsx +0 -29
- package/src/personal-access-tokens/web/i18n.ts +0 -69
- package/src/personal-access-tokens/web/index.ts +0 -7
- package/src/personal-access-tokens/web/pat-tokens-screen.tsx +0 -251
- package/src/sessions/__tests__/auto-revoke-binding.test.ts +0 -71
- package/src/shared/chunked-entity-migration.ts +0 -89
- package/src/shared/index.ts +0 -7
- package/src/tags/__tests__/schemas.test.ts +0 -34
- package/src/tags/web/__tests__/tag-manager.test.tsx +0 -64
- package/src/user/__tests__/stream-tenant-backfill.integration.test.ts +0 -186
- package/src/user/db/queries/stream-tenant-backfill.ts +0 -112
- package/src/user-data-rights/__tests__/export-encrypted-fields.integration.test.ts +0 -152
- package/src/user-data-rights/__tests__/forget-hook-registry.integration.test.ts +0 -108
|
@@ -1,197 +0,0 @@
|
|
|
1
|
-
// Re-encrypt job for `encrypted: true` config values. Two jobs in one,
|
|
2
|
-
// because format detection makes them the same loop:
|
|
3
|
-
// - MIGRATION: legacy CONFIG_ENCRYPTION_KEY values (base64 blob, no key
|
|
4
|
-
// id) → envelope format under the current master key. After a clean
|
|
5
|
-
// run the legacy key can be dropped from the environment.
|
|
6
|
-
// - KEK-ROTATION: envelope values wrapped under an older kekVersion →
|
|
7
|
-
// re-encrypted under provider.currentVersion(). Config has no
|
|
8
|
-
// kek_version column (values live in a TEXT column), so unlike the
|
|
9
|
-
// secrets rotate job the version check parses the stored JSON.
|
|
10
|
-
//
|
|
11
|
-
// Idempotent: a re-run skips rows already on the current version. Every
|
|
12
|
-
// write goes through the event-store executor (config values are
|
|
13
|
-
// entity-backed — raw UPDATEs would be wiped by a projection rebuild),
|
|
14
|
-
// so each migration appends a normal `.updated` event whose payload
|
|
15
|
-
// carries the NEW envelope: after a full run even a from-scratch rebuild
|
|
16
|
-
// no longer needs the legacy key for the final state.
|
|
17
|
-
|
|
18
|
-
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
19
|
-
import {
|
|
20
|
-
createEventStoreExecutor,
|
|
21
|
-
createTenantDb,
|
|
22
|
-
type DbConnection,
|
|
23
|
-
type TenantDb,
|
|
24
|
-
} from "@cosmicdrift/kumiko-framework/db";
|
|
25
|
-
import type { JobHandlerFn, SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
26
|
-
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
27
|
-
import { type EnvelopeCipher, isStoredEnvelope } from "@cosmicdrift/kumiko-framework/secrets";
|
|
28
|
-
import { type ChunkedMigrationStopReason, runChunkedMigration } from "../../shared";
|
|
29
|
-
import { configValueEntity, configValuesTable } from "../table";
|
|
30
|
-
|
|
31
|
-
const DEFAULT_MAX_FAILURES = 10;
|
|
32
|
-
const SCAN_SLICE_SIZE = 100;
|
|
33
|
-
const SYSTEM_ROLES = ["system"] as const;
|
|
34
|
-
|
|
35
|
-
const executor = createEventStoreExecutor(configValuesTable, configValueEntity, {
|
|
36
|
-
entityName: "config-value",
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
export type ReencryptJobPayload = {
|
|
40
|
-
readonly maxDurationMs?: number;
|
|
41
|
-
readonly maxFailures?: number;
|
|
42
|
-
};
|
|
43
|
-
|
|
44
|
-
export type ReencryptJobResult = {
|
|
45
|
-
readonly migrated: number;
|
|
46
|
-
readonly failed: number;
|
|
47
|
-
readonly alreadyCurrent: number;
|
|
48
|
-
readonly stoppedReason: ChunkedMigrationStopReason;
|
|
49
|
-
};
|
|
50
|
-
|
|
51
|
-
function needsReencrypt(value: string, targetVersion: number): boolean {
|
|
52
|
-
// legacy single-key format (base64 — can never start with "{")
|
|
53
|
-
if (!value.startsWith("{")) return true;
|
|
54
|
-
try {
|
|
55
|
-
const parsed: unknown = JSON.parse(value);
|
|
56
|
-
if (!isStoredEnvelope(parsed)) return true;
|
|
57
|
-
return parsed.kekVersion !== targetVersion;
|
|
58
|
-
} catch {
|
|
59
|
-
// malformed JSON — let the decrypt attempt surface the real error
|
|
60
|
-
return true;
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export const reencryptJob: JobHandlerFn = async (rawPayload, ctx): Promise<void> => {
|
|
65
|
-
const payload = rawPayload as ReencryptJobPayload; // @cast-boundary engine-payload
|
|
66
|
-
const maybeCipher = ctx.configEncryption;
|
|
67
|
-
if (!maybeCipher) {
|
|
68
|
-
throw new InternalError({
|
|
69
|
-
message:
|
|
70
|
-
"[config:reencrypt] ctx.configEncryption missing — provide a master key " +
|
|
71
|
-
"(KUMIKO_SECRETS_MASTER_KEY_V<n>) so the boot wires the envelope cipher.",
|
|
72
|
-
});
|
|
73
|
-
}
|
|
74
|
-
// hoisted function declarations below capture these — pin the narrowed
|
|
75
|
-
// type explicitly so TS keeps it inside the closures
|
|
76
|
-
const cipher: EnvelopeCipher = maybeCipher;
|
|
77
|
-
const provider = ctx.masterKeyProvider;
|
|
78
|
-
if (!provider) {
|
|
79
|
-
throw new InternalError({
|
|
80
|
-
message:
|
|
81
|
-
"[config:reencrypt] ctx.masterKeyProvider missing — wire it via extraContext.masterKeyProvider at boot.",
|
|
82
|
-
});
|
|
83
|
-
}
|
|
84
|
-
if (!ctx.db) {
|
|
85
|
-
throw new InternalError({
|
|
86
|
-
message: "[config:reencrypt] ctx.db missing — job context requires a database connection.",
|
|
87
|
-
});
|
|
88
|
-
}
|
|
89
|
-
if (!ctx.registry) {
|
|
90
|
-
throw new InternalError({
|
|
91
|
-
message: "[config:reencrypt] ctx.registry missing — job context requires the registry.",
|
|
92
|
-
});
|
|
93
|
-
}
|
|
94
|
-
const db = ctx.db as DbConnection; // @cast-boundary db-operator
|
|
95
|
-
|
|
96
|
-
const encryptedKeys = [...ctx.registry.getAllConfigKeys()]
|
|
97
|
-
.filter(([, def]) => def.encrypted === true)
|
|
98
|
-
.map(([key]) => key);
|
|
99
|
-
|
|
100
|
-
const maxFailures = payload.maxFailures ?? DEFAULT_MAX_FAILURES;
|
|
101
|
-
const deadline = payload.maxDurationMs
|
|
102
|
-
? Date.now() + payload.maxDurationMs
|
|
103
|
-
: Number.POSITIVE_INFINITY;
|
|
104
|
-
|
|
105
|
-
const tdbCache = new Map<TenantId, TenantDb>();
|
|
106
|
-
function tdbFor(tenantId: TenantId): TenantDb {
|
|
107
|
-
let existing = tdbCache.get(tenantId);
|
|
108
|
-
if (!existing) {
|
|
109
|
-
existing = createTenantDb(db, tenantId, "system");
|
|
110
|
-
tdbCache.set(tenantId, existing);
|
|
111
|
-
}
|
|
112
|
-
return existing;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
type ConfigRow = {
|
|
116
|
-
id: string;
|
|
117
|
-
key: string;
|
|
118
|
-
value: string | null;
|
|
119
|
-
tenantId: string;
|
|
120
|
-
version: number;
|
|
121
|
-
};
|
|
122
|
-
|
|
123
|
-
let alreadyCurrent = 0;
|
|
124
|
-
const targetVersion = provider.currentVersion();
|
|
125
|
-
|
|
126
|
-
// ponytail: one full candidate scan — config rows are operator-scale
|
|
127
|
-
// (tenants × encrypted keys), cursor pagination when that ever changes.
|
|
128
|
-
// Served to the shared loop in slices so its deadline/signal/failure
|
|
129
|
-
// checks run between chunks, not only once.
|
|
130
|
-
let pending: ConfigRow[] | undefined;
|
|
131
|
-
async function nextBatch(): Promise<readonly ConfigRow[]> {
|
|
132
|
-
if (pending === undefined) {
|
|
133
|
-
pending =
|
|
134
|
-
encryptedKeys.length === 0
|
|
135
|
-
? []
|
|
136
|
-
: [
|
|
137
|
-
...(await selectMany<ConfigRow>(db, configValuesTable, {
|
|
138
|
-
key: { in: encryptedKeys },
|
|
139
|
-
})),
|
|
140
|
-
];
|
|
141
|
-
}
|
|
142
|
-
const slice = pending;
|
|
143
|
-
return slice.splice(0, SCAN_SLICE_SIZE);
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
async function migrateRow(row: ConfigRow): Promise<"migrated" | "skipped" | "failed"> {
|
|
147
|
-
if (row.value === null || row.value === undefined) return "skipped";
|
|
148
|
-
if (!needsReencrypt(row.value, targetVersion)) {
|
|
149
|
-
alreadyCurrent++;
|
|
150
|
-
return "skipped";
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
// decrypt failure (missing legacy key, unknown kekVersion, tamper)
|
|
154
|
-
// throws → counted as failed via onRowError; the row stays untouched —
|
|
155
|
-
// never write anything we couldn't read.
|
|
156
|
-
const tenantId = row.tenantId as TenantId; // @cast-boundary db-row
|
|
157
|
-
const plaintext = await cipher.decrypt(row.value, { tenantId });
|
|
158
|
-
const reencrypted = await cipher.encrypt(plaintext, { tenantId });
|
|
159
|
-
|
|
160
|
-
const actor: SessionUser = { id: "system", tenantId, roles: SYSTEM_ROLES };
|
|
161
|
-
const result = await executor.update(
|
|
162
|
-
{ id: row.id, version: row.version, changes: { value: reencrypted } },
|
|
163
|
-
actor,
|
|
164
|
-
tdbFor(tenantId),
|
|
165
|
-
);
|
|
166
|
-
|
|
167
|
-
// version_conflict == a concurrent config:set beat us; the row now
|
|
168
|
-
// holds a fresh envelope written by the set handler — already fine.
|
|
169
|
-
if (!result.isSuccess) {
|
|
170
|
-
if (result.error.code === "version_conflict") return "skipped";
|
|
171
|
-
ctx.log?.warn?.(`[config:reencrypt] executor rejected row ${row.id}`, {
|
|
172
|
-
code: result.error.code,
|
|
173
|
-
});
|
|
174
|
-
return "failed";
|
|
175
|
-
}
|
|
176
|
-
return "migrated";
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
const outcome = await runChunkedMigration<ConfigRow>({
|
|
180
|
-
nextBatch,
|
|
181
|
-
migrateRow,
|
|
182
|
-
maxFailures,
|
|
183
|
-
deadlineAt: deadline,
|
|
184
|
-
signal: ctx.signal,
|
|
185
|
-
onRowError: (row, err) => {
|
|
186
|
-
ctx.log?.warn?.(`[config:reencrypt] failed to re-encrypt row ${row.id}`, { err });
|
|
187
|
-
},
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
const result: ReencryptJobResult = {
|
|
191
|
-
migrated: outcome.migrated,
|
|
192
|
-
failed: outcome.failed,
|
|
193
|
-
alreadyCurrent,
|
|
194
|
-
stoppedReason: outcome.stoppedReason,
|
|
195
|
-
};
|
|
196
|
-
ctx.log?.info?.(`[config:reencrypt] complete: ${JSON.stringify(result)}`);
|
|
197
|
-
};
|
|
@@ -1,204 +0,0 @@
|
|
|
1
|
-
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
-
import { randomBytes } from "node:crypto";
|
|
3
|
-
import {
|
|
4
|
-
createInMemoryLoginRateLimiter,
|
|
5
|
-
type PatResolver,
|
|
6
|
-
} from "@cosmicdrift/kumiko-framework/api";
|
|
7
|
-
import { updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
8
|
-
import type { SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
9
|
-
import {
|
|
10
|
-
setupTestStack,
|
|
11
|
-
type TestStack,
|
|
12
|
-
testTenantId,
|
|
13
|
-
unsafeCreateEntityTable,
|
|
14
|
-
unsafePushTables,
|
|
15
|
-
} from "@cosmicdrift/kumiko-framework/stack";
|
|
16
|
-
import { createTestEnvelopeCipher, deleteRows } from "@cosmicdrift/kumiko-framework/testing";
|
|
17
|
-
import { Temporal } from "temporal-polyfill";
|
|
18
|
-
import { AuthHandlers } from "../../auth-email-password/constants";
|
|
19
|
-
import { createAuthEmailPasswordFeature } from "../../auth-email-password/feature";
|
|
20
|
-
import { createConfigFeature } from "../../config";
|
|
21
|
-
import { createConfigResolver } from "../../config/resolver";
|
|
22
|
-
import { configValuesTable } from "../../config/table";
|
|
23
|
-
import { makeSessionHelpers } from "../../sessions/__tests__/test-helpers";
|
|
24
|
-
import { SessionQueries } from "../../sessions/constants";
|
|
25
|
-
import { createSessionsFeature } from "../../sessions/feature";
|
|
26
|
-
import { userSessionEntity } from "../../sessions/schema/user-session";
|
|
27
|
-
import { createTenantFeature } from "../../tenant";
|
|
28
|
-
import { tenantMembershipsTable } from "../../tenant/membership-table";
|
|
29
|
-
import { tenantEntity } from "../../tenant/schema/tenant";
|
|
30
|
-
import { createUserFeature } from "../../user/feature";
|
|
31
|
-
import { userEntity } from "../../user/schema/user";
|
|
32
|
-
import { PatHandlers, PatQueries } from "../constants";
|
|
33
|
-
import { createPersonalAccessTokensFeature } from "../feature";
|
|
34
|
-
import { createPatResolver } from "../resolver";
|
|
35
|
-
import { apiTokenEntity, apiTokenTable } from "../schema/api-token";
|
|
36
|
-
import type { PatScopeConfig } from "../scopes";
|
|
37
|
-
|
|
38
|
-
// Full loop, no mocks: mint a PAT via the create handler → use it as a bearer
|
|
39
|
-
// token over real HTTP. The resolver hashes it, resolves live roles, and the
|
|
40
|
-
// API boundary enforces the granted scopes. Proves the security matrix:
|
|
41
|
-
// allowed → 200, out-of-scope → 403 (while a JWT user could call it),
|
|
42
|
-
// revoked/expired/unknown/membership-removed → 401.
|
|
43
|
-
|
|
44
|
-
let stack: TestStack;
|
|
45
|
-
let h: ReturnType<typeof makeSessionHelpers>;
|
|
46
|
-
let patResolver: PatResolver | undefined;
|
|
47
|
-
|
|
48
|
-
const encryptionKey = randomBytes(32).toString("base64");
|
|
49
|
-
const TENANT: TenantId = testTenantId(1);
|
|
50
|
-
|
|
51
|
-
// One domain "tokens" whose read set is exactly the two PAT queries —
|
|
52
|
-
// deliberately NOT sessions:query:user-session:mine, so that QN is the
|
|
53
|
-
// out-of-scope probe. Granted as "tokens:read".
|
|
54
|
-
const SCOPES: PatScopeConfig = {
|
|
55
|
-
tokens: { label: "Tokens", read: [PatQueries.mine, PatQueries.availableScopes] },
|
|
56
|
-
};
|
|
57
|
-
|
|
58
|
-
async function mintToken(
|
|
59
|
-
actor: SessionUser,
|
|
60
|
-
opts?: { scopes?: string[]; expiresInDays?: number },
|
|
61
|
-
): Promise<string> {
|
|
62
|
-
const res = await stack.http.writeOk<{ id: string; token: string }>(
|
|
63
|
-
PatHandlers.create,
|
|
64
|
-
{
|
|
65
|
-
name: "test",
|
|
66
|
-
scopes: opts?.scopes ?? ["tokens:read"],
|
|
67
|
-
...(opts?.expiresInDays ? { expiresInDays: opts.expiresInDays } : {}),
|
|
68
|
-
},
|
|
69
|
-
actor,
|
|
70
|
-
);
|
|
71
|
-
return res.token;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
beforeAll(async () => {
|
|
75
|
-
const encryption = createTestEnvelopeCipher(encryptionKey);
|
|
76
|
-
const resolver = createConfigResolver({ cipher: encryption });
|
|
77
|
-
|
|
78
|
-
stack = await setupTestStack({
|
|
79
|
-
features: [
|
|
80
|
-
createConfigFeature(),
|
|
81
|
-
createUserFeature(),
|
|
82
|
-
createTenantFeature(),
|
|
83
|
-
createAuthEmailPasswordFeature(),
|
|
84
|
-
createSessionsFeature(),
|
|
85
|
-
createPersonalAccessTokensFeature({ scopes: SCOPES }),
|
|
86
|
-
],
|
|
87
|
-
extraContext: { configResolver: resolver, configEncryption: encryption },
|
|
88
|
-
authConfig: {
|
|
89
|
-
membershipQuery: "tenant:query:memberships",
|
|
90
|
-
loginHandler: AuthHandlers.login,
|
|
91
|
-
patResolver: (raw: string) => {
|
|
92
|
-
if (!patResolver) throw new Error("resolver not set");
|
|
93
|
-
return patResolver(raw);
|
|
94
|
-
},
|
|
95
|
-
// Low per-token cap so the rate-limit test can exhaust it. Other tests do
|
|
96
|
-
// ≤2 requests per (distinct) token, so this ceiling never trips them.
|
|
97
|
-
patRateLimiter: createInMemoryLoginRateLimiter(3, 60_000),
|
|
98
|
-
},
|
|
99
|
-
});
|
|
100
|
-
patResolver = createPatResolver({ db: stack.db, scopes: SCOPES });
|
|
101
|
-
h = makeSessionHelpers(stack, TENANT);
|
|
102
|
-
|
|
103
|
-
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
104
|
-
await unsafeCreateEntityTable(stack.db, tenantEntity);
|
|
105
|
-
await unsafeCreateEntityTable(stack.db, userSessionEntity);
|
|
106
|
-
await unsafeCreateEntityTable(stack.db, apiTokenEntity);
|
|
107
|
-
await unsafePushTables(stack.db, { configValuesTable, tenantMembershipsTable });
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
afterAll(async () => {
|
|
111
|
-
await stack.cleanup();
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
async function actorFor(email: string): Promise<SessionUser> {
|
|
115
|
-
const { userId } = await h.seedUser(email, "pw");
|
|
116
|
-
return { id: userId, tenantId: TENANT, roles: ["User"] };
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
// Raw login → JWT (no sid: session callbacks aren't wired here, and the PAT
|
|
120
|
-
// path doesn't need them). Used only to prove a non-PAT bearer skips the gate.
|
|
121
|
-
async function loginToken(email: string): Promise<string> {
|
|
122
|
-
const res = await stack.http.raw("POST", "/api/auth/login", { email, password: "pw" });
|
|
123
|
-
expect(res.status).toBe(200);
|
|
124
|
-
return ((await res.json()) as { token: string }).token;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
describe("PAT auth", () => {
|
|
128
|
-
test("granted scope → 200", async () => {
|
|
129
|
-
const actor = await actorFor("granted@example.com");
|
|
130
|
-
const token = await mintToken(actor);
|
|
131
|
-
const res = await h.authedPost("/api/query", token, { type: PatQueries.mine, payload: {} });
|
|
132
|
-
expect(res.status).toBe(200);
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
test("out-of-scope QN → 403 (but a JWT user may call it)", async () => {
|
|
136
|
-
const actor = await actorFor("scope@example.com");
|
|
137
|
-
const token = await mintToken(actor);
|
|
138
|
-
const denied = await h.authedPost("/api/query", token, {
|
|
139
|
-
type: SessionQueries.mine,
|
|
140
|
-
payload: {},
|
|
141
|
-
});
|
|
142
|
-
expect(denied.status).toBe(403);
|
|
143
|
-
|
|
144
|
-
const jwt = await loginToken("scope@example.com");
|
|
145
|
-
const allowed = await h.authedPost("/api/query", jwt, {
|
|
146
|
-
type: SessionQueries.mine,
|
|
147
|
-
payload: {},
|
|
148
|
-
});
|
|
149
|
-
expect(allowed.status).toBe(200);
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
test("unknown token → 401", async () => {
|
|
153
|
-
const res = await h.authedPost("/api/query", "kpat_deadbeef", {
|
|
154
|
-
type: PatQueries.mine,
|
|
155
|
-
payload: {},
|
|
156
|
-
});
|
|
157
|
-
expect(res.status).toBe(401);
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
test("revoked token → 401", async () => {
|
|
161
|
-
const actor = await actorFor("revoked@example.com");
|
|
162
|
-
const token = await mintToken(actor);
|
|
163
|
-
const rows = await stack.http.queryOk<Array<{ id: string }>>(PatQueries.mine, {}, actor);
|
|
164
|
-
const id = rows[0]?.id;
|
|
165
|
-
expect(id).toBeDefined();
|
|
166
|
-
await stack.http.writeOk(PatHandlers.revoke, { id }, actor);
|
|
167
|
-
const res = await h.authedPost("/api/query", token, { type: PatQueries.mine, payload: {} });
|
|
168
|
-
expect(res.status).toBe(401);
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
test("expired token → 401", async () => {
|
|
172
|
-
const actor = await actorFor("expired@example.com");
|
|
173
|
-
const token = await mintToken(actor);
|
|
174
|
-
const past = Temporal.Now.instant().subtract({ hours: 1 });
|
|
175
|
-
const rows =
|
|
176
|
-
(await stack.http.queryOk<Array<{ id: string }>>(PatQueries.mine, {}, actor)) ?? [];
|
|
177
|
-
await updateMany(stack.db, apiTokenTable, { expiresAt: past }, { id: rows[0]?.id });
|
|
178
|
-
const res = await h.authedPost("/api/query", token, { type: PatQueries.mine, payload: {} });
|
|
179
|
-
expect(res.status).toBe(401);
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
test("per-token rate limit → 429 once the cap is exceeded", async () => {
|
|
183
|
-
const actor = await actorFor("ratelimit@example.com");
|
|
184
|
-
const token = await mintToken(actor);
|
|
185
|
-
for (let i = 0; i < 3; i++) {
|
|
186
|
-
const ok = await h.authedPost("/api/query", token, { type: PatQueries.mine, payload: {} });
|
|
187
|
-
expect(ok.status).toBe(200);
|
|
188
|
-
}
|
|
189
|
-
const limited = await h.authedPost("/api/query", token, { type: PatQueries.mine, payload: {} });
|
|
190
|
-
expect(limited.status).toBe(429);
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
test("membership removed → 401 (live roles, no snapshot)", async () => {
|
|
194
|
-
const actor = await actorFor("removed@example.com");
|
|
195
|
-
const token = await mintToken(actor);
|
|
196
|
-
const ok = await h.authedPost("/api/query", token, { type: PatQueries.mine, payload: {} });
|
|
197
|
-
expect(ok.status).toBe(200);
|
|
198
|
-
// tenantMembershipsTable is ES-managed (executor-only branded); deleteRows
|
|
199
|
-
// is the test-side escape for out-of-band row removal.
|
|
200
|
-
await deleteRows(stack.db, tenantMembershipsTable, { userId: actor.id });
|
|
201
|
-
const res = await h.authedPost("/api/query", token, { type: PatQueries.mine, payload: {} });
|
|
202
|
-
expect(res.status).toBe(401);
|
|
203
|
-
});
|
|
204
|
-
});
|
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "bun:test";
|
|
2
|
-
import { expandScopes, type PatScopeConfig, parseGrant } from "../scopes";
|
|
3
|
-
|
|
4
|
-
const CONFIG: PatScopeConfig = {
|
|
5
|
-
credit: { label: "Kredite", read: ["credit:query:*"], write: ["credit:write:*"] },
|
|
6
|
-
miete: { label: "Mieten", read: ["ledger:query:*"] }, // read-only domain
|
|
7
|
-
};
|
|
8
|
-
|
|
9
|
-
describe("parseGrant", () => {
|
|
10
|
-
it("splits domain:level on the last colon", () => {
|
|
11
|
-
expect(parseGrant("credit:write")).toEqual({ domain: "credit", level: "write" });
|
|
12
|
-
});
|
|
13
|
-
it("rejects a bare token", () => {
|
|
14
|
-
expect(parseGrant("credit")).toBeNull();
|
|
15
|
-
});
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
describe("expandScopes", () => {
|
|
19
|
-
it("read grants only the read QNs", () => {
|
|
20
|
-
expect(expandScopes(CONFIG, ["credit:read"])).toEqual(["credit:query:*"]);
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
it("write grants read + write QNs", () => {
|
|
24
|
-
expect(expandScopes(CONFIG, ["credit:write"]).sort()).toEqual(
|
|
25
|
-
["credit:query:*", "credit:write:*"].sort(),
|
|
26
|
-
);
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
it("read-only domain ignores a write grant's missing write set", () => {
|
|
30
|
-
expect(expandScopes(CONFIG, ["miete:write"])).toEqual(["ledger:query:*"]);
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
it("unknown domain contributes nothing (fail-closed)", () => {
|
|
34
|
-
expect(expandScopes(CONFIG, ["ghost:write"])).toEqual([]);
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
it("unions across multiple grants without duplicates", () => {
|
|
38
|
-
expect(expandScopes(CONFIG, ["credit:read", "miete:read"]).sort()).toEqual(
|
|
39
|
-
["credit:query:*", "ledger:query:*"].sort(),
|
|
40
|
-
);
|
|
41
|
-
});
|
|
42
|
-
});
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
// @runtime client
|
|
2
|
-
// Pure constants — client-marked so the web screen (web/) may import screen-id
|
|
3
|
-
// and QN names without pulling the feature's server runtime barrel.
|
|
4
|
-
export const PAT_FEATURE = "personal-access-tokens";
|
|
5
|
-
|
|
6
|
-
// Snake_case reason strings (Error-Reasons guard: no colons/dashes).
|
|
7
|
-
export const PatErrors = {
|
|
8
|
-
ownershipDenied: "ownership_denied",
|
|
9
|
-
} as const;
|
|
10
|
-
|
|
11
|
-
// Dormant custom-screen id (r.screen) — the app places it via r.nav. The client
|
|
12
|
-
// maps it to the PatTokensScreen component.
|
|
13
|
-
export const PAT_SCREEN_ID = "api-tokens";
|
|
14
|
-
|
|
15
|
-
export const PatHandlers = {
|
|
16
|
-
create: "personal-access-tokens:write:create",
|
|
17
|
-
revoke: "personal-access-tokens:write:revoke",
|
|
18
|
-
} as const;
|
|
19
|
-
|
|
20
|
-
export const PatQueries = {
|
|
21
|
-
mine: "personal-access-tokens:query:mine",
|
|
22
|
-
availableScopes: "personal-access-tokens:query:available-scopes",
|
|
23
|
-
} as const;
|
|
24
|
-
|
|
25
|
-
// Only the first chars of a minted token are stored (alongside the hash) so the
|
|
26
|
-
// UI can show "kpat_ab12…" for recognition without ever holding the secret.
|
|
27
|
-
export const PAT_PREFIX_DISPLAY_LENGTH = 12;
|
|
28
|
-
|
|
29
|
-
export type PatRateLimit = { readonly maxRequests: number; readonly windowMs: number };
|
|
30
|
-
|
|
31
|
-
// Default per-token request rate for PAT-authenticated calls. Generous enough
|
|
32
|
-
// for normal automation, low enough to blunt a leaked-token abuse spike.
|
|
33
|
-
export const PAT_DEFAULT_RATE_LIMIT: PatRateLimit = { maxRequests: 120, windowMs: 60_000 };
|
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
import { buildEntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
-
import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
-
import { PAT_DEFAULT_RATE_LIMIT, PAT_FEATURE, PAT_SCREEN_ID, type PatRateLimit } from "./constants";
|
|
4
|
-
import { buildAvailableScopesQuery } from "./handlers/available-scopes.query";
|
|
5
|
-
import { createPatWrite } from "./handlers/create.write";
|
|
6
|
-
import { listPatQuery } from "./handlers/list.query";
|
|
7
|
-
import { revokePatWrite } from "./handlers/revoke.write";
|
|
8
|
-
import { apiTokenEntity } from "./schema/api-token";
|
|
9
|
-
import type { PatScopeConfig } from "./scopes";
|
|
10
|
-
|
|
11
|
-
export type PersonalAccessTokensOptions = {
|
|
12
|
-
// The scopes this deployment offers. Each is a named bundle of QN globs a PAT
|
|
13
|
-
// may be granted (a scope can span features). Closed over by available-scopes
|
|
14
|
-
// (UI list) and exported so run-prod-app can build the resolver from the same
|
|
15
|
-
// single source.
|
|
16
|
-
readonly scopes: PatScopeConfig;
|
|
17
|
-
// Per-token request rate limit for PAT-authenticated calls. Defaults to
|
|
18
|
-
// PAT_DEFAULT_RATE_LIMIT (120/60s). run-prod-app builds the limiter from this.
|
|
19
|
-
readonly rateLimit?: PatRateLimit;
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
export type PatFeatureExports = {
|
|
23
|
-
readonly scopes: PatScopeConfig;
|
|
24
|
-
readonly rateLimit: PatRateLimit;
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
// Personal Access Tokens — long-lived, revocable bearer credentials for the
|
|
28
|
-
// HTTP API. Like `sessions`, the hot-path resolver is NOT a handler: it runs on
|
|
29
|
-
// every PAT-authenticated request and does a direct-DB point-read (see
|
|
30
|
-
// createPatResolver / run-prod-app wiring). The dispatcher-side handlers here
|
|
31
|
-
// only mint/list/revoke tokens and expose the scope catalog.
|
|
32
|
-
export function createPersonalAccessTokensFeature(
|
|
33
|
-
options: PersonalAccessTokensOptions,
|
|
34
|
-
): FeatureDefinition {
|
|
35
|
-
const { scopes } = options;
|
|
36
|
-
return defineFeature(PAT_FEATURE, (r) => {
|
|
37
|
-
r.describe(
|
|
38
|
-
"Long-lived, revocable Personal Access Tokens for headless HTTP-API access. Stores SHA-256 token hashes in the `read_api_tokens` direct-write table; the plaintext is returned once at creation. `create`/`revoke`/`mine` manage a user's own tokens and `available-scopes` lists the app-declared scope catalog. Bearer tokens carrying the PAT prefix are resolved before jwt.verify (roles resolved live, granted scopes enforced fail-closed at the API boundary) — the resolver is wired via run-prod-app, not the dispatcher.",
|
|
39
|
-
);
|
|
40
|
-
r.uiHints({ displayLabel: "Personal Access Tokens", category: "identity", recommended: false });
|
|
41
|
-
// Resolver reads memberships + users on every PAT request to build live
|
|
42
|
-
// roles — make both boot-time deps so a mis-wiring fails validateBoot.
|
|
43
|
-
r.requires("user", "tenant");
|
|
44
|
-
// Direct-write store like read_user_sessions: create/revoke write it, the
|
|
45
|
-
// resolver point-reads it. r.entity would make it a rebuildable projection
|
|
46
|
-
// whose replay (no token events) would wipe every live token (#498/#494).
|
|
47
|
-
r.unmanagedTable(buildEntityTableMeta("api-token", apiTokenEntity), {
|
|
48
|
-
reason: "read_side.api_tokens_direct_write",
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
const handlers = {
|
|
52
|
-
create: r.writeHandler(createPatWrite),
|
|
53
|
-
revoke: r.writeHandler(revokePatWrite),
|
|
54
|
-
};
|
|
55
|
-
const queries = {
|
|
56
|
-
mine: r.queryHandler(listPatQuery),
|
|
57
|
-
availableScopes: r.queryHandler(buildAvailableScopesQuery(scopes)),
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
// Dormant custom-screen — the client maps PAT_SCREEN_ID to PatTokensScreen;
|
|
61
|
-
// the app places it via r.nav in its logged-in settings area.
|
|
62
|
-
r.screen({
|
|
63
|
-
id: PAT_SCREEN_ID,
|
|
64
|
-
type: "custom",
|
|
65
|
-
renderer: { react: { __component: "PatTokensScreen" } },
|
|
66
|
-
access: { openToAll: true },
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
// scopes + rateLimit flow into feature.exports so run-prod-app builds the
|
|
70
|
-
// resolver + limiter from the same declaration — single source of truth.
|
|
71
|
-
return {
|
|
72
|
-
handlers,
|
|
73
|
-
queries,
|
|
74
|
-
scopes,
|
|
75
|
-
rateLimit: options.rateLimit ?? PAT_DEFAULT_RATE_LIMIT,
|
|
76
|
-
} satisfies { handlers: unknown; queries: unknown } & PatFeatureExports;
|
|
77
|
-
});
|
|
78
|
-
}
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
-
import { z } from "zod";
|
|
3
|
-
import type { PatScopeConfig } from "../scopes";
|
|
4
|
-
|
|
5
|
-
// Returns the app-declared scope domains ({name, label, canWrite}) so the mint
|
|
6
|
-
// UI can render a per-domain level picker (no access / read / read & write).
|
|
7
|
-
// canWrite is false for read-only domains → the UI hides the write option.
|
|
8
|
-
// Static per deployment, not per user.
|
|
9
|
-
export function buildAvailableScopesQuery(scopes: PatScopeConfig) {
|
|
10
|
-
return defineQueryHandler({
|
|
11
|
-
name: "available-scopes",
|
|
12
|
-
schema: z.object({}),
|
|
13
|
-
access: { openToAll: true },
|
|
14
|
-
handler: async () =>
|
|
15
|
-
Object.entries(scopes).map(([name, def]) => ({
|
|
16
|
-
name,
|
|
17
|
-
label: def.label,
|
|
18
|
-
canWrite: (def.write?.length ?? 0) > 0,
|
|
19
|
-
})),
|
|
20
|
-
});
|
|
21
|
-
}
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
import { insertOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
-
import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
-
import { generateId } from "@cosmicdrift/kumiko-framework/utils";
|
|
4
|
-
import { Temporal } from "temporal-polyfill";
|
|
5
|
-
import { z } from "zod";
|
|
6
|
-
import { mintPatToken } from "../hash";
|
|
7
|
-
import { apiTokenTable } from "../schema/api-token";
|
|
8
|
-
|
|
9
|
-
// Mint a PAT for the calling user in their active tenant. The plaintext token
|
|
10
|
-
// is returned ONCE (data.token) and never again — only the hash is stored.
|
|
11
|
-
// `scopes` are granted scope names; unknown names simply grant nothing at
|
|
12
|
-
// resolve time (fail-closed), so no cross-check against the app config here.
|
|
13
|
-
export const createPatWrite = defineWriteHandler({
|
|
14
|
-
name: "create",
|
|
15
|
-
schema: z.object({
|
|
16
|
-
name: z.string().min(1).max(120),
|
|
17
|
-
scopes: z.array(z.string().min(1)).min(1),
|
|
18
|
-
expiresInDays: z.number().int().positive().max(3650).optional(),
|
|
19
|
-
}),
|
|
20
|
-
access: { openToAll: true },
|
|
21
|
-
handler: async (event, ctx) => {
|
|
22
|
-
const { raw, hash, prefix } = mintPatToken();
|
|
23
|
-
const now = Temporal.Now.instant();
|
|
24
|
-
const id = generateId();
|
|
25
|
-
await insertOne(ctx.db, apiTokenTable, {
|
|
26
|
-
id,
|
|
27
|
-
userId: event.user.id,
|
|
28
|
-
tenantId: event.user.tenantId,
|
|
29
|
-
name: event.payload.name,
|
|
30
|
-
tokenHash: hash,
|
|
31
|
-
prefix,
|
|
32
|
-
scopes: JSON.stringify(event.payload.scopes),
|
|
33
|
-
createdAt: now,
|
|
34
|
-
expiresAt: event.payload.expiresInDays
|
|
35
|
-
? now.add({ hours: 24 * event.payload.expiresInDays })
|
|
36
|
-
: null,
|
|
37
|
-
revokedAt: null,
|
|
38
|
-
});
|
|
39
|
-
return { isSuccess: true, data: { id, token: raw, prefix } };
|
|
40
|
-
},
|
|
41
|
-
});
|
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
-
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
-
import { z } from "zod";
|
|
4
|
-
import { apiTokenTable } from "../schema/api-token";
|
|
5
|
-
|
|
6
|
-
// The caller's own tokens — metadata only, never the hash or plaintext.
|
|
7
|
-
// Includes revoked/expired rows so the UI can show history; `prefix` is the
|
|
8
|
-
// only fragment of the secret ever exposed.
|
|
9
|
-
export const listPatQuery = defineQueryHandler({
|
|
10
|
-
name: "mine",
|
|
11
|
-
schema: z.object({}),
|
|
12
|
-
access: { openToAll: true },
|
|
13
|
-
handler: async (query, ctx) => {
|
|
14
|
-
const rows = await selectMany<{
|
|
15
|
-
id: string;
|
|
16
|
-
name: string;
|
|
17
|
-
prefix: string;
|
|
18
|
-
scopes: string;
|
|
19
|
-
createdAt: unknown;
|
|
20
|
-
expiresAt: unknown;
|
|
21
|
-
revokedAt: unknown;
|
|
22
|
-
}>(
|
|
23
|
-
ctx.db,
|
|
24
|
-
apiTokenTable,
|
|
25
|
-
{ userId: query.user.id },
|
|
26
|
-
{ orderBy: { col: "createdAt", direction: "desc" } },
|
|
27
|
-
);
|
|
28
|
-
return rows.map((r) => ({
|
|
29
|
-
id: r.id,
|
|
30
|
-
name: r.name,
|
|
31
|
-
prefix: r.prefix,
|
|
32
|
-
scopes: parseScopeNames(r.scopes),
|
|
33
|
-
createdAt: r.createdAt,
|
|
34
|
-
expiresAt: r.expiresAt,
|
|
35
|
-
revokedAt: r.revokedAt,
|
|
36
|
-
}));
|
|
37
|
-
},
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
function parseScopeNames(raw: string): string[] {
|
|
41
|
-
try {
|
|
42
|
-
const parsed: unknown = JSON.parse(raw);
|
|
43
|
-
return Array.isArray(parsed) ? parsed.filter((s): s is string => typeof s === "string") : [];
|
|
44
|
-
} catch {
|
|
45
|
-
return [];
|
|
46
|
-
}
|
|
47
|
-
}
|