@cosmicdrift/kumiko-bundled-features 0.256.0 → 0.258.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 +9 -10
- package/src/auth-mfa/__tests__/enable-flow.integration.test.ts +78 -6
- package/src/auth-mfa/__tests__/screen.test.ts +58 -0
- package/src/auth-mfa/feature.ts +6 -16
- package/src/auth-mfa/handlers/enable-start.write.ts +21 -12
- package/src/auth-mfa/i18n.ts +19 -0
- package/src/auth-mfa/screens.ts +52 -0
- package/src/auth-mfa/web/client-plugin.ts +4 -5
- package/src/auth-mfa/web/i18n.ts +0 -7
- package/src/auth-mfa/web/index.ts +0 -2
- package/src/auth-mfa/web/mfa-disable-dialog.tsx +2 -2
- package/src/auth-mfa/web/mfa-recovery-codes-reveal.tsx +4 -4
- package/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +5 -5
- package/src/auth-mfa/web/qrcode-browser.d.ts +2 -2
- package/src/folders/__tests__/folders-read-gate.integration.test.ts +179 -0
- package/src/folders/entity.ts +47 -28
- package/src/folders/feature.ts +58 -4
- package/src/folders/handlers/clear-folder.write.ts +4 -4
- package/src/folders/handlers/set-folder.write.ts +4 -4
- package/src/ledger/__tests__/ledger.integration.test.ts +111 -4
- package/src/ledger/entity.ts +30 -0
- package/src/ledger/handlers/confirm-schedule-period.write.ts +7 -0
- package/src/ledger/handlers/create-transaction.write.ts +2 -0
- package/src/ledger/schemas.ts +3 -0
- package/src/notes-history/__tests__/add-note-author-name-kms.integration.test.ts +2 -1
- package/src/notes-history/__tests__/notes-history-ownership.integration.test.ts +4 -1
- package/src/notes-history/__tests__/notes-history-parent-visibility.integration.test.ts +3 -1
- package/src/notes-history/__tests__/notes-history.integration.test.ts +2 -1
- package/src/notes-history/__tests__/notes-read-gate.integration.test.ts +243 -0
- package/src/notes-history/entity.ts +11 -1
- package/src/notes-history/feature.ts +12 -11
- package/src/notes-history/handlers/add-note.write.ts +8 -12
- package/src/personal-access-tokens/__tests__/expiry.test.ts +19 -0
- package/src/personal-access-tokens/__tests__/feature.test.ts +93 -0
- package/src/personal-access-tokens/__tests__/pat.integration.test.ts +143 -13
- package/src/personal-access-tokens/constants.ts +5 -2
- package/src/personal-access-tokens/expiry.ts +7 -0
- package/src/personal-access-tokens/feature.ts +8 -17
- package/src/personal-access-tokens/handlers/list.query.ts +75 -16
- package/src/personal-access-tokens/i18n.ts +54 -0
- package/src/personal-access-tokens/resolver.ts +2 -7
- package/src/personal-access-tokens/screens.ts +107 -0
- package/src/shared/index.ts +1 -1
- package/src/shared/parent-visibility.ts +30 -0
- package/src/tags/__tests__/tags-read-gate.integration.test.ts +366 -0
- package/src/tags/__tests__/tags.integration.test.ts +1 -0
- package/src/tags/entity.ts +11 -1
- package/src/tags/feature.ts +25 -8
- package/src/tags/handlers/assign-tag.write.ts +2 -8
- package/src/tags/handlers/remove-tag.write.ts +2 -8
- package/src/tags/web/__tests__/entity-tags.test.tsx +13 -4
- package/src/tags/web/entity-tags.tsx +9 -2
- package/src/tags/web/tag-section.tsx +9 -4
- package/src/auth-mfa/web/__tests__/mfa-enable-screen.test.tsx +0 -182
- package/src/auth-mfa/web/mfa-enable-screen.tsx +0 -224
- package/src/personal-access-tokens/web/client-plugin.tsx +0 -29
- package/src/personal-access-tokens/web/i18n.ts +0 -40
- package/src/personal-access-tokens/web/index.ts +0 -7
- package/src/personal-access-tokens/web/pat-tokens-screen.tsx +0 -296
|
@@ -55,6 +55,10 @@ import type { PatScopeConfig } from "../scopes";
|
|
|
55
55
|
let stack: TestStack;
|
|
56
56
|
let h: ReturnType<typeof makeSessionHelpers>;
|
|
57
57
|
|
|
58
|
+
// `personal-access-tokens:query:mine` is now a paged handler — every caller
|
|
59
|
+
// reads `response.rows` instead of a blank array (fw#2548 Teil B).
|
|
60
|
+
type PatRowsEnvelope<T> = { readonly rows: readonly T[]; readonly nextCursor: string | null };
|
|
61
|
+
|
|
58
62
|
const encryptionKey = randomBytes(32).toString("base64");
|
|
59
63
|
const TENANT: TenantId = testTenantId(1);
|
|
60
64
|
|
|
@@ -183,7 +187,11 @@ describe("PAT auth", () => {
|
|
|
183
187
|
test("revoked token → 401", async () => {
|
|
184
188
|
const actor = await actorFor("revoked@example.com");
|
|
185
189
|
const token = await mintToken(actor);
|
|
186
|
-
const rows = await stack.http.queryOk<
|
|
190
|
+
const { rows } = await stack.http.queryOk<PatRowsEnvelope<{ id: string }>>(
|
|
191
|
+
PatQueries.mine,
|
|
192
|
+
{},
|
|
193
|
+
actor,
|
|
194
|
+
);
|
|
187
195
|
const id = rows[0]?.id;
|
|
188
196
|
expect(id).toBeDefined();
|
|
189
197
|
await stack.http.writeOk(PatHandlers.revoke, { id }, actor);
|
|
@@ -195,8 +203,11 @@ describe("PAT auth", () => {
|
|
|
195
203
|
const actor = await actorFor("expired@example.com");
|
|
196
204
|
const token = await mintToken(actor);
|
|
197
205
|
const past = Temporal.Now.instant().subtract({ hours: 1 });
|
|
198
|
-
const rows =
|
|
199
|
-
|
|
206
|
+
const { rows } = await stack.http.queryOk<PatRowsEnvelope<{ id: string }>>(
|
|
207
|
+
PatQueries.mine,
|
|
208
|
+
{},
|
|
209
|
+
actor,
|
|
210
|
+
);
|
|
200
211
|
await updateMany(stack.db, apiTokenTable, { expiresAt: past }, { id: rows[0]?.id });
|
|
201
212
|
const res = await h.authedPost("/api/query", token, { type: PatQueries.mine, payload: {} });
|
|
202
213
|
expect(res.status).toBe(401);
|
|
@@ -245,7 +256,7 @@ describe("PAT with active KMS (#820): token name is userOwned PII", () => {
|
|
|
245
256
|
const actor = await actorFor("kms-pat@example.com");
|
|
246
257
|
const token = await mintToken(actor);
|
|
247
258
|
|
|
248
|
-
const rows = await stack.http.queryOk<
|
|
259
|
+
const { rows } = await stack.http.queryOk<PatRowsEnvelope<{ id: string; name: string }>>(
|
|
249
260
|
PatQueries.mine,
|
|
250
261
|
{},
|
|
251
262
|
actor,
|
|
@@ -276,7 +287,11 @@ describe("PAT create: re-auth (#security)", () => {
|
|
|
276
287
|
actor,
|
|
277
288
|
);
|
|
278
289
|
expect(err.httpStatus).toBeGreaterThanOrEqual(400);
|
|
279
|
-
const rows = await stack.http.queryOk<
|
|
290
|
+
const { rows } = await stack.http.queryOk<PatRowsEnvelope<{ id: string }>>(
|
|
291
|
+
PatQueries.mine,
|
|
292
|
+
{},
|
|
293
|
+
actor,
|
|
294
|
+
);
|
|
280
295
|
expect(rows).toHaveLength(0);
|
|
281
296
|
});
|
|
282
297
|
|
|
@@ -288,18 +303,20 @@ describe("PAT create: re-auth (#security)", () => {
|
|
|
288
303
|
actor,
|
|
289
304
|
);
|
|
290
305
|
expect(err.details).toMatchObject({ reason: PatErrors.reauthRequired });
|
|
291
|
-
const rows = await stack.http.queryOk<
|
|
306
|
+
const { rows } = await stack.http.queryOk<PatRowsEnvelope<{ id: string }>>(
|
|
307
|
+
PatQueries.mine,
|
|
308
|
+
{},
|
|
309
|
+
actor,
|
|
310
|
+
);
|
|
292
311
|
expect(rows).toHaveLength(0);
|
|
293
312
|
});
|
|
294
313
|
|
|
295
314
|
test("expiresInDays omitted → defaults to ~90 days, not never-expiring", async () => {
|
|
296
315
|
const actor = await actorFor("reauth-expiry@example.com");
|
|
297
316
|
await mintToken(actor);
|
|
298
|
-
const rows = await stack.http.queryOk<
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
actor,
|
|
302
|
-
);
|
|
317
|
+
const { rows } = await stack.http.queryOk<
|
|
318
|
+
PatRowsEnvelope<{ id: string; expiresAt: string | null }>
|
|
319
|
+
>(PatQueries.mine, {}, actor);
|
|
303
320
|
expect(rows[0]?.expiresAt).not.toBeNull();
|
|
304
321
|
const expiresAt = Temporal.Instant.from(rows[0]?.expiresAt as string);
|
|
305
322
|
const expected = Temporal.Now.instant().add({ hours: 24 * PAT_DEFAULT_EXPIRES_IN_DAYS });
|
|
@@ -361,7 +378,11 @@ describe("PAT create: MFA re-auth gate", () => {
|
|
|
361
378
|
actor,
|
|
362
379
|
);
|
|
363
380
|
expect(err.details).toMatchObject({ reason: PatErrors.reauthRequired });
|
|
364
|
-
const rows = await mfaStack.http.queryOk<
|
|
381
|
+
const { rows } = await mfaStack.http.queryOk<PatRowsEnvelope<{ id: string }>>(
|
|
382
|
+
PatQueries.mine,
|
|
383
|
+
{},
|
|
384
|
+
actor,
|
|
385
|
+
);
|
|
365
386
|
expect(rows).toHaveLength(0);
|
|
366
387
|
});
|
|
367
388
|
|
|
@@ -374,7 +395,11 @@ describe("PAT create: MFA re-auth gate", () => {
|
|
|
374
395
|
actor,
|
|
375
396
|
);
|
|
376
397
|
expect(err.details).toMatchObject({ reason: PatErrors.reauthRequired });
|
|
377
|
-
const rows = await mfaStack.http.queryOk<
|
|
398
|
+
const { rows } = await mfaStack.http.queryOk<PatRowsEnvelope<{ id: string }>>(
|
|
399
|
+
PatQueries.mine,
|
|
400
|
+
{},
|
|
401
|
+
actor,
|
|
402
|
+
);
|
|
378
403
|
expect(rows).toHaveLength(0);
|
|
379
404
|
});
|
|
380
405
|
});
|
|
@@ -420,3 +445,108 @@ describe("PAT revoke on password change (#security)", () => {
|
|
|
420
445
|
expect(res.status).toBe(200);
|
|
421
446
|
});
|
|
422
447
|
});
|
|
448
|
+
|
|
449
|
+
describe("PAT list projection (fw#2548 Teil B)", () => {
|
|
450
|
+
test("the minted plaintext token is returned exactly once and never by query:mine", async () => {
|
|
451
|
+
const actor = await actorFor("secret-once@example.com");
|
|
452
|
+
const token = await mintToken(actor);
|
|
453
|
+
|
|
454
|
+
const first = await stack.http.queryOk<PatRowsEnvelope<{ prefix: string }>>(
|
|
455
|
+
PatQueries.mine,
|
|
456
|
+
{},
|
|
457
|
+
actor,
|
|
458
|
+
);
|
|
459
|
+
expect(JSON.stringify(first)).not.toContain(token);
|
|
460
|
+
expect(first.rows[0]?.prefix).toBeDefined();
|
|
461
|
+
|
|
462
|
+
// A second read confirms this isn't a one-request fluke — the plaintext
|
|
463
|
+
// never lands in the projection at all, so a later read can't leak it
|
|
464
|
+
// either.
|
|
465
|
+
const second = await stack.http.queryOk<PatRowsEnvelope<{ prefix: string }>>(
|
|
466
|
+
PatQueries.mine,
|
|
467
|
+
{},
|
|
468
|
+
actor,
|
|
469
|
+
);
|
|
470
|
+
expect(JSON.stringify(second)).not.toContain(token);
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
test("another user's tokens never appear in query:mine", async () => {
|
|
474
|
+
const actorA = await actorFor("owner-a@example.com");
|
|
475
|
+
const actorB = await actorFor("owner-b@example.com");
|
|
476
|
+
await mintToken(actorA);
|
|
477
|
+
await mintToken(actorB);
|
|
478
|
+
|
|
479
|
+
const { rows: rowsA } = await stack.http.queryOk<PatRowsEnvelope<{ id: string }>>(
|
|
480
|
+
PatQueries.mine,
|
|
481
|
+
{},
|
|
482
|
+
actorA,
|
|
483
|
+
);
|
|
484
|
+
const { rows: rowsB } = await stack.http.queryOk<PatRowsEnvelope<{ id: string }>>(
|
|
485
|
+
PatQueries.mine,
|
|
486
|
+
{},
|
|
487
|
+
actorB,
|
|
488
|
+
);
|
|
489
|
+
expect(rowsA).toHaveLength(1);
|
|
490
|
+
expect(rowsB).toHaveLength(1);
|
|
491
|
+
expect(rowsA[0]?.id).not.toBe(rowsB[0]?.id);
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
test("revoke of another user's token fails and leaves it usable", async () => {
|
|
495
|
+
const actorA = await actorFor("revoke-owner-a@example.com");
|
|
496
|
+
const actorB = await actorFor("revoke-owner-b@example.com");
|
|
497
|
+
const tokenA = await mintToken(actorA);
|
|
498
|
+
const { rows } = await stack.http.queryOk<PatRowsEnvelope<{ id: string }>>(
|
|
499
|
+
PatQueries.mine,
|
|
500
|
+
{},
|
|
501
|
+
actorA,
|
|
502
|
+
);
|
|
503
|
+
const tokenAId = rows[0]?.id;
|
|
504
|
+
expect(tokenAId).toBeDefined();
|
|
505
|
+
|
|
506
|
+
const err = await stack.http.writeErr(PatHandlers.revoke, { id: tokenAId }, actorB);
|
|
507
|
+
expect(err.details).toMatchObject({ reason: PatErrors.ownershipDenied });
|
|
508
|
+
|
|
509
|
+
const res = await h.authedPost("/api/query", tokenA, { type: PatQueries.mine, payload: {} });
|
|
510
|
+
expect(res.status).toBe(200);
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
test("status column reflects revoked/expired/active", async () => {
|
|
514
|
+
const actor = await actorFor("status-column@example.com");
|
|
515
|
+
await mintToken(actor);
|
|
516
|
+
const active = await stack.http.queryOk<PatRowsEnvelope<{ id: string; status: string }>>(
|
|
517
|
+
PatQueries.mine,
|
|
518
|
+
{},
|
|
519
|
+
actor,
|
|
520
|
+
);
|
|
521
|
+
expect(active.rows[0]?.status).toBe("active");
|
|
522
|
+
|
|
523
|
+
await stack.http.writeOk(PatHandlers.revoke, { id: active.rows[0]?.id }, actor);
|
|
524
|
+
const revoked = await stack.http.queryOk<PatRowsEnvelope<{ id: string; status: string }>>(
|
|
525
|
+
PatQueries.mine,
|
|
526
|
+
{},
|
|
527
|
+
actor,
|
|
528
|
+
);
|
|
529
|
+
expect(revoked.rows[0]?.status).toBe("revoked");
|
|
530
|
+
|
|
531
|
+
const expiredActor = await actorFor("status-column-expired@example.com");
|
|
532
|
+
await mintToken(expiredActor);
|
|
533
|
+
const beforeExpiry = await stack.http.queryOk<PatRowsEnvelope<{ id: string }>>(
|
|
534
|
+
PatQueries.mine,
|
|
535
|
+
{},
|
|
536
|
+
expiredActor,
|
|
537
|
+
);
|
|
538
|
+
const past = Temporal.Now.instant().subtract({ hours: 1 });
|
|
539
|
+
await updateMany(
|
|
540
|
+
stack.db,
|
|
541
|
+
apiTokenTable,
|
|
542
|
+
{ expiresAt: past },
|
|
543
|
+
{ id: beforeExpiry.rows[0]?.id },
|
|
544
|
+
);
|
|
545
|
+
const expired = await stack.http.queryOk<PatRowsEnvelope<{ id: string; status: string }>>(
|
|
546
|
+
PatQueries.mine,
|
|
547
|
+
{},
|
|
548
|
+
expiredActor,
|
|
549
|
+
);
|
|
550
|
+
expect(expired.rows[0]?.status).toBe("expired");
|
|
551
|
+
});
|
|
552
|
+
});
|
|
@@ -16,10 +16,13 @@ export const PatErrors = {
|
|
|
16
16
|
// lets callers who genuinely want a long-lived token pass it explicitly.
|
|
17
17
|
export const PAT_DEFAULT_EXPIRES_IN_DAYS = 90;
|
|
18
18
|
|
|
19
|
-
//
|
|
20
|
-
//
|
|
19
|
+
// Declarative projectionList screen id — the caller's own tokens, revoke as a
|
|
20
|
+
// row action. The app places it via r.nav.
|
|
21
21
|
export const PAT_SCREEN_ID = "api-tokens";
|
|
22
22
|
|
|
23
|
+
// Declarative secretMint screen id — mints a new token, reveals it once.
|
|
24
|
+
export const PAT_MINT_SCREEN_ID = "api-token-create";
|
|
25
|
+
|
|
23
26
|
export const PatHandlers = {
|
|
24
27
|
create: "personal-access-tokens:write:create",
|
|
25
28
|
revoke: "personal-access-tokens:write:revoke",
|
|
@@ -5,15 +5,16 @@ import {
|
|
|
5
5
|
import { PAT_TOKEN_PREFIX } from "@cosmicdrift/kumiko-framework/api";
|
|
6
6
|
import { deriveEntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
|
|
7
7
|
import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
-
import { PAT_DEFAULT_RATE_LIMIT, PAT_FEATURE,
|
|
8
|
+
import { PAT_DEFAULT_RATE_LIMIT, PAT_FEATURE, type PatRateLimit } from "./constants";
|
|
9
9
|
import { buildAvailableScopesQuery } from "./handlers/available-scopes.query";
|
|
10
10
|
import { type CreatePatOptions, createPatCreateHandler } from "./handlers/create.write";
|
|
11
11
|
import { listPatQuery } from "./handlers/list.query";
|
|
12
12
|
import { revokePatWrite } from "./handlers/revoke.write";
|
|
13
|
-
import { PAT_FEATURE_I18N } from "./i18n";
|
|
13
|
+
import { PAT_FEATURE_I18N, patScopeOptionTranslations } from "./i18n";
|
|
14
14
|
import { createPatResolver } from "./resolver";
|
|
15
15
|
import { apiTokenEntity } from "./schema/api-token";
|
|
16
16
|
import type { PatScopeConfig } from "./scopes";
|
|
17
|
+
import { createPatMintScreen, patListScreen } from "./screens";
|
|
17
18
|
|
|
18
19
|
// Password-change is the only field-level trigger — see the postSave hook
|
|
19
20
|
// below. MFA-enable/disable is wired separately (auth-mfa's
|
|
@@ -145,21 +146,11 @@ export function createPersonalAccessTokensFeature(
|
|
|
145
146
|
availableScopes: r.queryHandler(buildAvailableScopesQuery(scopes)),
|
|
146
147
|
};
|
|
147
148
|
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
r.screen({
|
|
154
|
-
id: PAT_SCREEN_ID,
|
|
155
|
-
type: "custom",
|
|
156
|
-
renderer: { react: { __component: "PatTokensScreen" } },
|
|
157
|
-
access: { openToAll: true },
|
|
158
|
-
dormant: true,
|
|
159
|
-
description:
|
|
160
|
-
"Self-service screen where a signed-in user reviews their personal access tokens, mints a new one by picking a permission level per scope domain and an expiry, and revokes tokens they no longer need.",
|
|
161
|
-
});
|
|
162
|
-
r.translations({ keys: PAT_FEATURE_I18N });
|
|
149
|
+
// Declarative screens — list-with-revoke + mint-with-reveal. The app
|
|
150
|
+
// places `patListScreen` via r.nav in its logged-in settings area.
|
|
151
|
+
r.screen(patListScreen);
|
|
152
|
+
r.screen(createPatMintScreen(scopes));
|
|
153
|
+
r.translations({ keys: { ...PAT_FEATURE_I18N, ...patScopeOptionTranslations(scopes) } });
|
|
163
154
|
|
|
164
155
|
// rateLimit flows into feature.exports so run-prod-app builds the
|
|
165
156
|
// limiter from the same declaration — single source of truth. `scopes`
|
|
@@ -1,52 +1,111 @@
|
|
|
1
1
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
-
import {
|
|
2
|
+
import { definePagedQueryHandler, MAX_LIST_LIMIT } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { decryptStoredPii } from "../../shared";
|
|
5
|
+
import { isExpiredAt } from "../expiry";
|
|
5
6
|
import { apiTokenTable } from "../schema/api-token";
|
|
6
7
|
|
|
8
|
+
// `sort` arrives raw from the client's query string. selectMany's orderBy has
|
|
9
|
+
// no column-existence check — an unrecognised field just gets snake_cased and
|
|
10
|
+
// quoted as-is (bun-db/query.ts columnOf) — so this allowlist is what stops a
|
|
11
|
+
// client sorting by tokenHash or any other non-exposed column.
|
|
12
|
+
const SORTABLE_COLUMNS = ["id", "name", "createdAt", "expiresAt", "revokedAt"] as const;
|
|
13
|
+
type SortableColumn = (typeof SORTABLE_COLUMNS)[number];
|
|
14
|
+
|
|
15
|
+
function isSortableColumn(value: string): value is SortableColumn {
|
|
16
|
+
return (SORTABLE_COLUMNS as readonly string[]).includes(value);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type PatStatus = "active" | "revoked" | "expired";
|
|
20
|
+
|
|
21
|
+
function tokenStatus(row: {
|
|
22
|
+
revokedAt: unknown;
|
|
23
|
+
expiresAt: { epochMilliseconds: number } | null;
|
|
24
|
+
}): PatStatus {
|
|
25
|
+
if (row.revokedAt !== null) return "revoked";
|
|
26
|
+
if (isExpiredAt(row.expiresAt)) return "expired";
|
|
27
|
+
return "active";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function parseScopeNames(raw: string): string[] {
|
|
31
|
+
try {
|
|
32
|
+
const parsed: unknown = JSON.parse(raw);
|
|
33
|
+
return Array.isArray(parsed) ? parsed.filter((s): s is string => typeof s === "string") : [];
|
|
34
|
+
} catch {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
7
39
|
// The caller's own tokens — metadata only, never the hash or plaintext.
|
|
8
40
|
// Includes revoked/expired rows so the UI can show history; `prefix` is the
|
|
9
41
|
// only fragment of the secret ever exposed.
|
|
10
|
-
export const listPatQuery =
|
|
42
|
+
export const listPatQuery = definePagedQueryHandler({
|
|
11
43
|
name: "mine",
|
|
12
|
-
schema: z.object({
|
|
44
|
+
schema: z.object({
|
|
45
|
+
limit: z.number().int().nonnegative().max(MAX_LIST_LIMIT).optional(),
|
|
46
|
+
sort: z.string().optional(),
|
|
47
|
+
sortDirection: z.enum(["asc", "desc"]).optional(),
|
|
48
|
+
}),
|
|
13
49
|
access: { openToAll: true },
|
|
14
50
|
description:
|
|
15
|
-
"Lists the calling user's personal access tokens with metadata only (name, key prefix, scopes, created/expiry/revoked timestamps, including revoked ones) and never the token secret.",
|
|
51
|
+
"Lists the calling user's personal access tokens with metadata only (name, key prefix, scopes, computed status, created/expiry/revoked timestamps, including revoked ones) and never the token secret.",
|
|
52
|
+
outputSchema: z.object({
|
|
53
|
+
rows: z.array(
|
|
54
|
+
z.object({
|
|
55
|
+
id: z.string(),
|
|
56
|
+
name: z.string(),
|
|
57
|
+
prefix: z.string(),
|
|
58
|
+
scopes: z.array(z.string()),
|
|
59
|
+
status: z.enum(["active", "revoked", "expired"]),
|
|
60
|
+
createdAt: z.unknown(),
|
|
61
|
+
expiresAt: z.unknown(),
|
|
62
|
+
revokedAt: z.unknown(),
|
|
63
|
+
}),
|
|
64
|
+
),
|
|
65
|
+
nextCursor: z.string().nullable(),
|
|
66
|
+
}),
|
|
16
67
|
handler: async (query, ctx) => {
|
|
68
|
+
const requestedSort = query.payload.sort;
|
|
69
|
+
const sortColumn: SortableColumn =
|
|
70
|
+
requestedSort !== undefined && isSortableColumn(requestedSort) ? requestedSort : "createdAt";
|
|
17
71
|
const rows = await selectMany<{
|
|
18
72
|
id: string;
|
|
19
73
|
name: string;
|
|
20
74
|
prefix: string;
|
|
21
75
|
scopes: string;
|
|
22
76
|
createdAt: unknown;
|
|
23
|
-
expiresAt:
|
|
77
|
+
expiresAt: { epochMilliseconds: number } | null;
|
|
24
78
|
revokedAt: unknown;
|
|
25
79
|
}>(
|
|
26
80
|
ctx.db,
|
|
27
81
|
apiTokenTable,
|
|
28
82
|
{ userId: query.user.id },
|
|
29
|
-
{
|
|
83
|
+
{
|
|
84
|
+
// `id` as a tie-breaker keeps row order (and, with `limit` set, row
|
|
85
|
+
// selection) deterministic across identical requests — sortColumn
|
|
86
|
+
// alone isn't unique (e.g. many NULL revokedAt, or equal timestamps).
|
|
87
|
+
orderBy:
|
|
88
|
+
sortColumn === "id"
|
|
89
|
+
? { col: "id", direction: query.payload.sortDirection ?? "desc" }
|
|
90
|
+
: [
|
|
91
|
+
{ col: sortColumn, direction: query.payload.sortDirection ?? "desc" },
|
|
92
|
+
{ col: "id", direction: "asc" },
|
|
93
|
+
],
|
|
94
|
+
...(query.payload.limit !== undefined && { limit: query.payload.limit }),
|
|
95
|
+
},
|
|
30
96
|
);
|
|
31
|
-
|
|
97
|
+
const decryptedRows = await Promise.all(
|
|
32
98
|
rows.map(async (r) => ({
|
|
33
99
|
id: r.id,
|
|
34
100
|
name: await decryptStoredPii(r.name, "name", "pat:list"),
|
|
35
101
|
prefix: r.prefix,
|
|
36
102
|
scopes: parseScopeNames(r.scopes),
|
|
103
|
+
status: tokenStatus(r),
|
|
37
104
|
createdAt: r.createdAt,
|
|
38
105
|
expiresAt: r.expiresAt,
|
|
39
106
|
revokedAt: r.revokedAt,
|
|
40
107
|
})),
|
|
41
108
|
);
|
|
109
|
+
return { rows: decryptedRows, nextCursor: null };
|
|
42
110
|
},
|
|
43
111
|
});
|
|
44
|
-
|
|
45
|
-
function parseScopeNames(raw: string): string[] {
|
|
46
|
-
try {
|
|
47
|
-
const parsed: unknown = JSON.parse(raw);
|
|
48
|
-
return Array.isArray(parsed) ? parsed.filter((s): s is string => typeof s === "string") : [];
|
|
49
|
-
} catch {
|
|
50
|
-
return [];
|
|
51
|
-
}
|
|
52
|
-
}
|
|
@@ -1,5 +1,59 @@
|
|
|
1
|
+
import type { PatScopeConfig } from "./scopes";
|
|
2
|
+
|
|
1
3
|
type LocalizedString = { readonly en: string };
|
|
2
4
|
|
|
3
5
|
export const PAT_FEATURE_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
4
6
|
"screen:api-tokens.title": { en: "Personal Access Tokens" },
|
|
7
|
+
"screen:api-token-create.title": { en: "Create a new token" },
|
|
8
|
+
|
|
9
|
+
"pat.list.col.name": { en: "Name" },
|
|
10
|
+
"pat.list.col.prefix": { en: "Token" },
|
|
11
|
+
"pat.list.col.scopes": { en: "Scopes" },
|
|
12
|
+
"pat.list.col.status": { en: "Status" },
|
|
13
|
+
"pat.list.col.created": { en: "Created" },
|
|
14
|
+
"pat.list.col.expires": { en: "Expires" },
|
|
15
|
+
"pat.list.revoke": { en: "Revoke" },
|
|
16
|
+
"pat.list.revoke.confirm": { en: "Revoke this token? Anything using it will stop working." },
|
|
17
|
+
|
|
18
|
+
"pat.create.title": { en: "Create a new token" },
|
|
19
|
+
"pat.create.submit": { en: "Create token" },
|
|
20
|
+
"pat.create.reauth": { en: "Confirm your identity" },
|
|
21
|
+
|
|
22
|
+
"pat.created.title": { en: "Token created" },
|
|
23
|
+
"pat.created.token": { en: "Your new token" },
|
|
24
|
+
"pat.created.hint": { en: "Copy this token now — for security it is shown only this once." },
|
|
25
|
+
"pat.created.dismiss": { en: "Done" },
|
|
26
|
+
|
|
27
|
+
// Field labels for the secretMint's synthesized pseudo-entity
|
|
28
|
+
// (`__action-form__`, see action-form-shim.ts) — required by the i18n
|
|
29
|
+
// boot-validator (requiredKeysFromScreen); the declarative renderer
|
|
30
|
+
// resolves these through the schema payload, not a client component's t().
|
|
31
|
+
"personal-access-tokens:entity:__action-form__:field:name": { en: "Name" },
|
|
32
|
+
"personal-access-tokens:entity:__action-form__:field:scopes": { en: "Per-API access" },
|
|
33
|
+
"personal-access-tokens:entity:__action-form__:field:expiresInDays": { en: "Expires in (days)" },
|
|
34
|
+
"personal-access-tokens:entity:__action-form__:field:currentPassword": {
|
|
35
|
+
en: "Password (to confirm)",
|
|
36
|
+
},
|
|
37
|
+
"personal-access-tokens:entity:__action-form__:field:mfaCode": { en: "2FA code (if enabled)" },
|
|
5
38
|
};
|
|
39
|
+
|
|
40
|
+
// Per-grant-option labels for the mint form's `scopes` multiSelect —
|
|
41
|
+
// "<domain> (read)" / "<domain> (read & write)", matching the wording the
|
|
42
|
+
// former custom screen showed. `def.label` is an app-authored raw display
|
|
43
|
+
// string (not itself an i18n key).
|
|
44
|
+
export function patScopeOptionTranslations(
|
|
45
|
+
scopes: PatScopeConfig,
|
|
46
|
+
): Readonly<Record<string, LocalizedString>> {
|
|
47
|
+
const out: Record<string, LocalizedString> = {};
|
|
48
|
+
for (const [domain, def] of Object.entries(scopes)) {
|
|
49
|
+
out[`personal-access-tokens:entity:__action-form__:field:scopes:option:${domain}:read`] = {
|
|
50
|
+
en: `${def.label} (read)`,
|
|
51
|
+
};
|
|
52
|
+
if (def.write && def.write.length > 0) {
|
|
53
|
+
out[`personal-access-tokens:entity:__action-form__:field:scopes:option:${domain}:write`] = {
|
|
54
|
+
en: `${def.label} (read & write)`,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
@@ -2,7 +2,7 @@ import type { TokenVerifier } from "@cosmicdrift/kumiko-framework/api";
|
|
|
2
2
|
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
3
3
|
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
4
4
|
import type { SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
5
|
-
import {
|
|
5
|
+
import { isExpiredAt } from "./expiry";
|
|
6
6
|
import { hashPatToken } from "./hash";
|
|
7
7
|
import { resolvePatRoles } from "./roles";
|
|
8
8
|
import { apiTokenTable } from "./schema/api-token";
|
|
@@ -29,12 +29,7 @@ export function createPatResolver(opts: {
|
|
|
29
29
|
}>(db, apiTokenTable, { tokenHash: hashPatToken(rawToken) });
|
|
30
30
|
if (!row) return null;
|
|
31
31
|
if (row.revokedAt !== null) return null;
|
|
32
|
-
if (
|
|
33
|
-
row.expiresAt &&
|
|
34
|
-
row.expiresAt.epochMilliseconds <= Temporal.Now.instant().epochMilliseconds
|
|
35
|
-
) {
|
|
36
|
-
return null;
|
|
37
|
-
}
|
|
32
|
+
if (isExpiredAt(row.expiresAt)) return null;
|
|
38
33
|
const roles = await resolvePatRoles(db, row.userId, row.tenantId);
|
|
39
34
|
if (!roles) return null;
|
|
40
35
|
const granted = parseScopeNames(row.scopes);
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import {
|
|
2
|
+
i18nKey,
|
|
3
|
+
type ProjectionListScreenDefinition,
|
|
4
|
+
type SecretMintScreenDefinition,
|
|
5
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
6
|
+
import { PAT_MINT_SCREEN_ID, PAT_SCREEN_ID, PatHandlers, PatQueries } from "./constants";
|
|
7
|
+
import type { PatScopeConfig } from "./scopes";
|
|
8
|
+
|
|
9
|
+
export const patListScreen: ProjectionListScreenDefinition = {
|
|
10
|
+
id: PAT_SCREEN_ID,
|
|
11
|
+
type: "projectionList",
|
|
12
|
+
query: PatQueries.mine,
|
|
13
|
+
defaultSort: { field: "createdAt", dir: "desc" },
|
|
14
|
+
columns: [
|
|
15
|
+
{ field: "name", label: i18nKey("pat.list.col.name") },
|
|
16
|
+
{ field: "prefix", label: i18nKey("pat.list.col.prefix") },
|
|
17
|
+
{ field: "scopes", label: i18nKey("pat.list.col.scopes") },
|
|
18
|
+
{ field: "status", label: i18nKey("pat.list.col.status") },
|
|
19
|
+
{
|
|
20
|
+
field: "createdAt",
|
|
21
|
+
label: i18nKey("pat.list.col.created"),
|
|
22
|
+
renderer: { format: "timestamp" },
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
field: "expiresAt",
|
|
26
|
+
label: i18nKey("pat.list.col.expires"),
|
|
27
|
+
renderer: { format: "timestamp" },
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
rowActions: [
|
|
31
|
+
{
|
|
32
|
+
kind: "writeHandler",
|
|
33
|
+
id: "revoke",
|
|
34
|
+
label: i18nKey("pat.list.revoke"),
|
|
35
|
+
handler: PatHandlers.revoke,
|
|
36
|
+
style: "danger",
|
|
37
|
+
confirm: i18nKey("pat.list.revoke.confirm"),
|
|
38
|
+
visible: { field: "status", ne: "revoked" },
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
toolbarActions: [
|
|
42
|
+
{
|
|
43
|
+
kind: "navigate",
|
|
44
|
+
id: "create",
|
|
45
|
+
label: i18nKey("pat.create.title"),
|
|
46
|
+
screen: PAT_MINT_SCREEN_ID,
|
|
47
|
+
style: "primary",
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
access: { openToAll: true },
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// Grant-string vocabulary for a scope config's multiSelect field — a
|
|
54
|
+
// read-only domain offers only "<domain>:read", one with a non-empty
|
|
55
|
+
// `write` set additionally offers "<domain>:write" (matches expandScopes/
|
|
56
|
+
// parseGrant in scopes.ts).
|
|
57
|
+
export function patGrantOptions(scopes: PatScopeConfig): string[] {
|
|
58
|
+
const out: string[] = [];
|
|
59
|
+
for (const [domain, def] of Object.entries(scopes)) {
|
|
60
|
+
out.push(`${domain}:read`);
|
|
61
|
+
if (def.write && def.write.length > 0) out.push(`${domain}:write`);
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function createPatMintScreen(scopes: PatScopeConfig): SecretMintScreenDefinition {
|
|
67
|
+
return {
|
|
68
|
+
id: PAT_MINT_SCREEN_ID,
|
|
69
|
+
type: "secretMint",
|
|
70
|
+
handler: PatHandlers.create,
|
|
71
|
+
fields: {
|
|
72
|
+
name: { type: "text", required: true, maxLength: 120 },
|
|
73
|
+
scopes: {
|
|
74
|
+
type: "multiSelect",
|
|
75
|
+
required: true,
|
|
76
|
+
display: "checkboxes",
|
|
77
|
+
options: patGrantOptions(scopes),
|
|
78
|
+
},
|
|
79
|
+
expiresInDays: { type: "number", integer: true, min: 1, max: 3650, default: 90 },
|
|
80
|
+
currentPassword: { type: "text", required: true, format: "password", sensitive: true },
|
|
81
|
+
mfaCode: { type: "text" },
|
|
82
|
+
},
|
|
83
|
+
layout: {
|
|
84
|
+
sections: [
|
|
85
|
+
{ fields: ["name", "scopes", "expiresInDays"] },
|
|
86
|
+
// Separate section: create.write requires password (+ MFA code, if
|
|
87
|
+
// enrolled) even though the request is already session-authed.
|
|
88
|
+
{
|
|
89
|
+
title: i18nKey("pat.create.reauth"),
|
|
90
|
+
fields: ["currentPassword", "mfaCode"],
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
},
|
|
94
|
+
submitLabel: i18nKey("pat.create.submit"),
|
|
95
|
+
reveal: {
|
|
96
|
+
title: i18nKey("pat.created.title"),
|
|
97
|
+
warning: i18nKey("pat.created.hint"),
|
|
98
|
+
confirmLabel: i18nKey("pat.created.dismiss"),
|
|
99
|
+
fields: [
|
|
100
|
+
{ field: "token", label: i18nKey("pat.created.token"), display: "code", copyable: true },
|
|
101
|
+
],
|
|
102
|
+
},
|
|
103
|
+
redirect: PAT_SCREEN_ID,
|
|
104
|
+
cancelTarget: PAT_SCREEN_ID,
|
|
105
|
+
access: { openToAll: true },
|
|
106
|
+
};
|
|
107
|
+
}
|
package/src/shared/index.ts
CHANGED
|
@@ -17,7 +17,7 @@ export { isWithinGracePeriod } from "./grace-period";
|
|
|
17
17
|
export { hasWhereRule } from "./has-where-rule";
|
|
18
18
|
export { isIdentityV3Hash, verifyIdentityV3Hash } from "./identity-v3-hash";
|
|
19
19
|
export { mapWithConcurrency } from "./map-with-concurrency";
|
|
20
|
-
export { parentRowIsVisible } from "./parent-visibility";
|
|
20
|
+
export { joinRowParentIsVisible, parentRowIsVisible } from "./parent-visibility";
|
|
21
21
|
export { hashPassword, verifyDummyPassword, verifyPassword } from "./password-hashing";
|
|
22
22
|
export { sessionField } from "./session-field";
|
|
23
23
|
export { sessionLocaleField } from "./session-locale-field";
|
|
@@ -33,6 +33,10 @@ export async function parentRowIsVisible(
|
|
|
33
33
|
const entity = registry.getEntity(entityType);
|
|
34
34
|
if (!entity) return false;
|
|
35
35
|
|
|
36
|
+
// Mirrors the SQL gate, which drops join-row candidates for the same reason:
|
|
37
|
+
// resolving a host that is itself a join row would need recursive gating.
|
|
38
|
+
if (entity.parentRef !== undefined) return false;
|
|
39
|
+
|
|
36
40
|
// A malformed id would abort the write transaction on a Postgres cast
|
|
37
41
|
// error (22P02) instead of cleanly denying — reject it before it reaches
|
|
38
42
|
// the executor.
|
|
@@ -46,3 +50,29 @@ export async function parentRowIsVisible(
|
|
|
46
50
|
|
|
47
51
|
return (await executor.detail({ id: entityId }, user, db)) !== null;
|
|
48
52
|
}
|
|
53
|
+
|
|
54
|
+
// Write-path half of the parentRef gate (fw#2766). Resolves the join entity's
|
|
55
|
+
// own `parentRef` declaration from the registry — the same object the read
|
|
56
|
+
// path's SQL gate reads — so the field names and the allowedTypes allowlist
|
|
57
|
+
// exist in exactly one place instead of once per path.
|
|
58
|
+
export async function joinRowParentIsVisible(
|
|
59
|
+
registry: Registry,
|
|
60
|
+
joinEntityName: string,
|
|
61
|
+
payload: Readonly<Record<string, unknown>>,
|
|
62
|
+
user: SessionUser,
|
|
63
|
+
db: TenantDb,
|
|
64
|
+
): Promise<boolean> {
|
|
65
|
+
const parentRef = registry.getEntity(joinEntityName)?.parentRef;
|
|
66
|
+
// A mount whose join entity lost its declaration can't derive a gate —
|
|
67
|
+
// deny rather than silently fall back to the pre-#2766 open behaviour.
|
|
68
|
+
if (parentRef === undefined) return false;
|
|
69
|
+
|
|
70
|
+
const entityType = payload[parentRef.entityTypeField];
|
|
71
|
+
const entityId = payload[parentRef.entityIdField];
|
|
72
|
+
if (typeof entityType !== "string" || typeof entityId !== "string") return false;
|
|
73
|
+
if (parentRef.allowedTypes !== undefined && !parentRef.allowedTypes.includes(entityType)) {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return parentRowIsVisible(registry, entityType, entityId, user, db);
|
|
78
|
+
}
|