@cosmicdrift/kumiko-bundled-features 0.105.2 → 0.109.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 +8 -6
- package/src/auth-email-password/__tests__/auth-claims.integration.test.ts +3 -2
- package/src/auth-email-password/__tests__/email-verification.integration.test.ts +5 -9
- package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +3 -2
- package/src/auth-email-password/__tests__/multi-roles.integration.test.ts +3 -2
- package/src/auth-email-password/__tests__/password-reset.integration.test.ts +4 -3
- package/src/auth-email-password/handlers/change-password.write.ts +11 -11
- package/src/auth-email-password/handlers/confirm-token-flow.ts +33 -104
- package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +44 -0
- package/src/auth-email-password/web/__tests__/user-menu.test.tsx +14 -1
- package/src/auth-email-password/web/auth-gate.tsx +3 -4
- package/src/auth-email-password/web/invite-accept-screen.tsx +7 -3
- package/src/auth-email-password/web/session.tsx +5 -1
- package/src/auth-email-password/web/user-menu.tsx +1 -1
- package/src/config/__tests__/deserialize-value.test.ts +3 -6
- package/src/custom-fields/__tests__/custom-fields.integration.test.ts +65 -0
- package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +4 -3
- package/src/custom-fields/constants.ts +1 -0
- package/src/custom-fields/handlers/delete-system-field.write.ts +2 -1
- package/src/custom-fields/handlers/delete-tenant-field.write.ts +2 -1
- package/src/custom-fields/wire-for-entity.ts +104 -77
- package/src/data-retention/__tests__/parse-override.test.ts +11 -6
- package/src/data-retention/resolve-for-tenant.ts +16 -4
- package/src/data-retention/run-retention-cleanup.ts +72 -10
- package/src/delivery/attempt-log.ts +22 -10
- package/src/feature-toggles/__tests__/feature-toggles.integration.test.ts +4 -4
- package/src/folders/__tests__/folders.integration.test.ts +26 -0
- package/src/folders/feature.ts +4 -2
- package/src/folders/handlers/delete-folder.write.ts +40 -0
- package/src/folders/web/__tests__/folder-manager.test.tsx +24 -0
- package/src/folders/web/folder-manager.tsx +18 -13
- package/src/folders/web/i18n.ts +0 -2
- package/src/folders-user-data/__tests__/hooks.integration.test.ts +69 -0
- package/src/folders-user-data/hooks.ts +31 -8
- package/src/folders-user-data/index.ts +4 -4
- package/src/jobs/feature.ts +23 -21
- package/src/jobs/handlers/projection-rebuild.job.ts +14 -4
- package/src/ledger/__tests__/ledger.integration.test.ts +44 -0
- package/src/ledger/__tests__/reports.test.ts +20 -0
- package/src/ledger/entity.ts +1 -1
- package/src/ledger/handlers/confirm-schedule-period.write.ts +29 -13
- package/src/ledger/handlers/reverse-transaction.write.ts +44 -5
- package/src/ledger/recurring.ts +18 -9
- package/src/ledger/schemas.ts +4 -1
- package/src/ledger/web/index.ts +3 -2
- package/src/managed-pages/__tests__/branding-coerce.test.ts +2 -6
- package/src/personal-access-tokens/__tests__/pat.integration.test.ts +205 -0
- package/src/personal-access-tokens/__tests__/scopes.test.ts +42 -0
- package/src/personal-access-tokens/constants.ts +33 -0
- package/src/personal-access-tokens/feature.ts +78 -0
- package/src/personal-access-tokens/handlers/available-scopes.query.ts +21 -0
- package/src/personal-access-tokens/handlers/create.write.ts +41 -0
- package/src/personal-access-tokens/handlers/list.query.ts +47 -0
- package/src/personal-access-tokens/handlers/revoke.write.ts +37 -0
- package/src/personal-access-tokens/hash.ts +17 -0
- package/src/personal-access-tokens/index.ts +45 -0
- package/src/personal-access-tokens/resolver.ts +58 -0
- package/src/personal-access-tokens/roles.ts +29 -0
- package/src/personal-access-tokens/schema/api-token.ts +56 -0
- package/src/personal-access-tokens/scopes.ts +43 -0
- package/src/personal-access-tokens/web/client-plugin.tsx +29 -0
- package/src/personal-access-tokens/web/i18n.ts +69 -0
- package/src/personal-access-tokens/web/index.ts +7 -0
- package/src/personal-access-tokens/web/pat-tokens-screen.tsx +251 -0
- package/src/renderer-simple/__tests__/template-resolver.integration.test.ts +2 -2
- package/src/sessions/__tests__/cleanup.integration.test.ts +3 -3
- package/src/sessions/__tests__/rebuild-survival.integration.test.ts +5 -9
- package/src/sessions/__tests__/sessions.integration.test.ts +14 -9
- package/src/sessions/schema/user-session.ts +8 -2
- package/src/sessions/session-callbacks.ts +9 -3
- package/src/subscription-stripe/__tests__/runtime.test.ts +14 -24
- package/src/tags/__tests__/tags.integration.test.ts +15 -0
- package/src/tags/handlers/assign-tag.write.ts +13 -3
- package/src/tags/schemas.ts +10 -4
- package/src/template-resolver/__tests__/template-resolver.integration.test.ts +2 -2
- package/src/template-resolver/testing.ts +4 -3
- package/src/tier-engine/feature.ts +22 -10
- package/src/tier-engine/handlers/set-tenant-tier.write.ts +11 -1
- package/src/user/__tests__/stream-tenant-backfill.integration.test.ts +186 -0
- package/src/user/db/queries/stream-tenant-backfill.ts +112 -0
- package/src/user/index.ts +4 -0
- package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +4 -4
- package/src/user-data-rights/__tests__/audit-log.integration.test.ts +4 -4
- package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +4 -4
- package/src/user-data-rights/__tests__/download.integration.test.ts +7 -7
- package/src/user-data-rights/__tests__/export-job-idempotency.integration.test.ts +5 -10
- package/src/user-data-rights/__tests__/export-job-list-cross-tenant.integration.test.ts +84 -0
- package/src/user-data-rights/__tests__/file-retention.integration.test.ts +3 -3
- package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +3 -2
- package/src/user-data-rights/__tests__/forget-test-helpers.ts +3 -2
- package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +3 -3
- package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +10 -6
- package/src/user-data-rights/__tests__/request-cancel-deletion.integration.test.ts +4 -4
- package/src/user-data-rights/__tests__/request-deletion-callback.integration.test.ts +4 -4
- package/src/user-data-rights/__tests__/request-export.integration.test.ts +4 -9
- package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +7 -3
- package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +8 -4
- package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +12 -17
- package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +9 -5
- package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +3 -3
- package/src/user-data-rights/handlers/download-attempt-list.query.ts +1 -1
- package/src/user-data-rights/handlers/download-by-job.query.ts +4 -6
- package/src/user-data-rights/handlers/export-job-detail.query.ts +1 -0
- package/src/user-data-rights/handlers/export-job-list.query.ts +1 -0
- package/src/user-data-rights/lib/update-user-lifecycle.ts +8 -24
- package/src/user-data-rights/run-forget-cleanup.ts +8 -1
- package/src/user-data-rights/web/__tests__/public-deletion-gate.test.tsx +7 -1
- package/src/user-data-rights/web/i18n.ts +0 -10
- package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +3 -2
- package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +102 -54
- package/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +53 -15
- package/src/user-profile/__tests__/profile-screen.test.tsx +9 -0
- package/src/user-profile/handlers/change-email.write.ts +11 -8
- package/src/user-profile/web/profile-screen.tsx +10 -8
- package/src/auth-email-password/stream-tenant.ts +0 -31
- package/src/user-data-rights-defaults/db/queries/user-hook.ts +0 -17
|
@@ -0,0 +1,47 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
4
|
+
import { Temporal } from "temporal-polyfill";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { PatErrors } from "../constants";
|
|
7
|
+
import { apiTokenTable } from "../schema/api-token";
|
|
8
|
+
|
|
9
|
+
// Revoke one of the caller's own tokens. Ownership is enforced in the WHERE
|
|
10
|
+
// (userId = caller), so a caller can't revoke another user's token and a miss
|
|
11
|
+
// is a uniform ownership error with no existence oracle. isNull(revokedAt)
|
|
12
|
+
// keeps a double-revoke from overwriting the original timestamp.
|
|
13
|
+
export const revokePatWrite = defineWriteHandler({
|
|
14
|
+
name: "revoke",
|
|
15
|
+
schema: z.object({ id: z.uuid() }),
|
|
16
|
+
access: { openToAll: true },
|
|
17
|
+
handler: async (event, ctx) => {
|
|
18
|
+
const updated = await updateMany(
|
|
19
|
+
ctx.db,
|
|
20
|
+
apiTokenTable,
|
|
21
|
+
{ revokedAt: Temporal.Now.instant() },
|
|
22
|
+
{ id: event.payload.id, userId: event.user.id, revokedAt: null },
|
|
23
|
+
);
|
|
24
|
+
if (updated.length > 0) return { isSuccess: true, data: { id: event.payload.id } };
|
|
25
|
+
return writeFailure(
|
|
26
|
+
new UnprocessableError(PatErrors.ownershipDenied, {
|
|
27
|
+
i18nKey: "errors.ownershipDenied",
|
|
28
|
+
details: {
|
|
29
|
+
scope: "entity",
|
|
30
|
+
entityName: "api-token",
|
|
31
|
+
action: "revoke",
|
|
32
|
+
userId: event.user.id,
|
|
33
|
+
},
|
|
34
|
+
}),
|
|
35
|
+
);
|
|
36
|
+
},
|
|
37
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { generateToken, PAT_TOKEN_PREFIX } from "@cosmicdrift/kumiko-framework/api";
|
|
3
|
+
import { PAT_PREFIX_DISPLAY_LENGTH } from "./constants";
|
|
4
|
+
|
|
5
|
+
// A PAT is high-entropy (32 random bytes from generateToken). A single SHA-256
|
|
6
|
+
// is the right hash here: fast on the per-request auth path, and there's no
|
|
7
|
+
// brute-force surface that would justify argon2 (that's for low-entropy
|
|
8
|
+
// passwords). Only the hash is persisted — the plaintext exists once, in the
|
|
9
|
+
// create response.
|
|
10
|
+
export function hashPatToken(rawToken: string): string {
|
|
11
|
+
return createHash("sha256").update(rawToken).digest("hex");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function mintPatToken(): { raw: string; hash: string; prefix: string } {
|
|
15
|
+
const raw = `${PAT_TOKEN_PREFIX}${generateToken()}`;
|
|
16
|
+
return { raw, hash: hashPatToken(raw), prefix: raw.slice(0, PAT_PREFIX_DISPLAY_LENGTH) };
|
|
17
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { PAT_DEFAULT_RATE_LIMIT, type PatRateLimit } from "./constants";
|
|
3
|
+
import type { PatScopeConfig } from "./scopes";
|
|
4
|
+
|
|
5
|
+
export type { PatRateLimit } from "./constants";
|
|
6
|
+
export { PAT_DEFAULT_RATE_LIMIT, PAT_FEATURE, PatHandlers, PatQueries } from "./constants";
|
|
7
|
+
export type { PersonalAccessTokensOptions } from "./feature";
|
|
8
|
+
export { createPersonalAccessTokensFeature } from "./feature";
|
|
9
|
+
export { hashPatToken, mintPatToken } from "./hash";
|
|
10
|
+
export { createPatResolver } from "./resolver";
|
|
11
|
+
export { apiTokenEntity, apiTokenTable } from "./schema/api-token";
|
|
12
|
+
export type { PatScopeConfig, PatScopeDef } from "./scopes";
|
|
13
|
+
export { expandScopes } from "./scopes";
|
|
14
|
+
|
|
15
|
+
// Reads the scope catalog off a mounted PAT feature's exports (set from the
|
|
16
|
+
// setup-callback return). run-prod-app uses this to build the resolver from the
|
|
17
|
+
// same config the handlers were given. Returns {} for a non-PAT / malformed
|
|
18
|
+
// definition — the caller has already matched on feature.name.
|
|
19
|
+
export function patScopesFromFeature(feature: FeatureDefinition): PatScopeConfig {
|
|
20
|
+
const exports = feature.exports;
|
|
21
|
+
if (exports && typeof exports === "object" && "scopes" in exports) {
|
|
22
|
+
const { scopes } = exports as { scopes: unknown };
|
|
23
|
+
if (scopes && typeof scopes === "object") return scopes as PatScopeConfig;
|
|
24
|
+
}
|
|
25
|
+
return {};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Reads the per-token rate-limit config off a mounted PAT feature's exports.
|
|
29
|
+
// Falls back to the default when absent. run-prod-app uses this to build the
|
|
30
|
+
// limiter from the same declaration the feature was given.
|
|
31
|
+
export function patRateLimitFromFeature(feature: FeatureDefinition): PatRateLimit {
|
|
32
|
+
const exports = feature.exports;
|
|
33
|
+
if (exports && typeof exports === "object" && "rateLimit" in exports) {
|
|
34
|
+
const { rateLimit } = exports as { rateLimit: unknown };
|
|
35
|
+
if (
|
|
36
|
+
rateLimit &&
|
|
37
|
+
typeof rateLimit === "object" &&
|
|
38
|
+
"maxRequests" in rateLimit &&
|
|
39
|
+
"windowMs" in rateLimit
|
|
40
|
+
) {
|
|
41
|
+
return rateLimit as PatRateLimit;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return PAT_DEFAULT_RATE_LIMIT;
|
|
45
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { PatResolver } from "@cosmicdrift/kumiko-framework/api";
|
|
2
|
+
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
3
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
4
|
+
import type { SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
5
|
+
import { Temporal } from "temporal-polyfill";
|
|
6
|
+
import { hashPatToken } from "./hash";
|
|
7
|
+
import { resolvePatRoles } from "./roles";
|
|
8
|
+
import { apiTokenTable } from "./schema/api-token";
|
|
9
|
+
import { expandScopes, type PatScopeConfig } from "./scopes";
|
|
10
|
+
|
|
11
|
+
// Hot-path resolver — raw-DB like session-callbacks (a dispatcher roundtrip on
|
|
12
|
+
// every request buys nothing here). Hash the bearer token, point-read the row,
|
|
13
|
+
// reject revoked/expired, resolve LIVE roles, expand granted scopes into
|
|
14
|
+
// allowedQns. Any failure returns null so the middleware answers a uniform 401
|
|
15
|
+
// with no oracle about which check failed.
|
|
16
|
+
export function createPatResolver(opts: {
|
|
17
|
+
readonly db: DbConnection;
|
|
18
|
+
readonly scopes: PatScopeConfig;
|
|
19
|
+
}): PatResolver {
|
|
20
|
+
const { db, scopes } = opts;
|
|
21
|
+
return async (rawToken: string): Promise<SessionUser | null> => {
|
|
22
|
+
const row = await fetchOne<{
|
|
23
|
+
id: string;
|
|
24
|
+
userId: string;
|
|
25
|
+
tenantId: string;
|
|
26
|
+
scopes: string;
|
|
27
|
+
revokedAt: unknown;
|
|
28
|
+
expiresAt: { epochMilliseconds: number } | null;
|
|
29
|
+
}>(db, apiTokenTable, { tokenHash: hashPatToken(rawToken) });
|
|
30
|
+
if (!row) return null;
|
|
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
|
+
}
|
|
38
|
+
const roles = await resolvePatRoles(db, row.userId, row.tenantId);
|
|
39
|
+
if (!roles) return null;
|
|
40
|
+
const granted = parseScopeNames(row.scopes);
|
|
41
|
+
return {
|
|
42
|
+
id: row.userId,
|
|
43
|
+
// @cast-boundary db-row → branded id: stored from a valid SessionUser.tenantId at create
|
|
44
|
+
tenantId: row.tenantId as TenantId,
|
|
45
|
+
roles,
|
|
46
|
+
pat: { tokenId: row.id, scopes: granted, allowedQns: expandScopes(scopes, granted) },
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function parseScopeNames(raw: string): string[] {
|
|
52
|
+
try {
|
|
53
|
+
const parsed: unknown = JSON.parse(raw);
|
|
54
|
+
return Array.isArray(parsed) ? parsed.filter((s): s is string => typeof s === "string") : [];
|
|
55
|
+
} catch {
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { fetchOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
+
import { stripForbiddenMembershipRoles } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
|
+
import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
|
|
5
|
+
import { tenantMembershipsTable } from "../tenant";
|
|
6
|
+
import { userTable } from "../user";
|
|
7
|
+
|
|
8
|
+
// Live role resolution for a (userId, tenantId), mirroring login.write.ts:
|
|
9
|
+
// global roles (users.roles) ∪ tenant-membership roles (forbidden roles
|
|
10
|
+
// stripped). Resolved fresh on every PAT request — a snapshot baked at mint
|
|
11
|
+
// time would keep a since-revoked admin role for the token's whole (months-long)
|
|
12
|
+
// life. Returns null when the user has no membership in that tenant: removed
|
|
13
|
+
// from the tenant → the PAT stops authenticating there.
|
|
14
|
+
export async function resolvePatRoles(
|
|
15
|
+
db: DbConnection,
|
|
16
|
+
userId: string,
|
|
17
|
+
tenantId: string,
|
|
18
|
+
): Promise<readonly string[] | null> {
|
|
19
|
+
const memberships = await selectMany<{ roles: string }>(db, tenantMembershipsTable, {
|
|
20
|
+
userId,
|
|
21
|
+
tenantId,
|
|
22
|
+
});
|
|
23
|
+
const membership = memberships[0];
|
|
24
|
+
if (!membership) return null;
|
|
25
|
+
const userRow = await fetchOne<{ roles: string | null }>(db, userTable, { id: userId });
|
|
26
|
+
const globalRoles = parseRoles(userRow?.roles ?? null);
|
|
27
|
+
const membershipRoles = stripForbiddenMembershipRoles(parseRoles(membership.roles));
|
|
28
|
+
return [...new Set([...globalRoles, ...membershipRoles])];
|
|
29
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { buildEntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import {
|
|
3
|
+
access,
|
|
4
|
+
createEntity,
|
|
5
|
+
createTextField,
|
|
6
|
+
createTimestampField,
|
|
7
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
+
|
|
9
|
+
// One row per Personal Access Token. Like read_user_sessions this is a
|
|
10
|
+
// direct-write store (r.unmanagedTable): the create/revoke handlers write it and
|
|
11
|
+
// the resolver point-reads it on the hot auth path. `tokenHash` is the SHA-256
|
|
12
|
+
// of the plaintext (never the plaintext); the unique index on it makes the
|
|
13
|
+
// resolver a single point-read. All columns are privileged-write so no user
|
|
14
|
+
// request can forge ownership/scope/hash by poking a field directly — the
|
|
15
|
+
// handlers mutate them via ctx.db inside the pipeline.
|
|
16
|
+
export const apiTokenEntity = createEntity({
|
|
17
|
+
table: "read_api_tokens",
|
|
18
|
+
// No softDelete: revocation is its own lifecycle (revokedAt timestamp), and
|
|
19
|
+
// we keep revoked rows for the "your tokens" audit list.
|
|
20
|
+
softDelete: false,
|
|
21
|
+
fields: {
|
|
22
|
+
userId: createTextField({
|
|
23
|
+
required: true,
|
|
24
|
+
maxLength: 36,
|
|
25
|
+
access: { write: access.privileged },
|
|
26
|
+
}),
|
|
27
|
+
tenantId: createTextField({
|
|
28
|
+
required: true,
|
|
29
|
+
maxLength: 36,
|
|
30
|
+
access: { write: access.privileged },
|
|
31
|
+
}),
|
|
32
|
+
name: createTextField({ required: true, maxLength: 120, access: { write: access.privileged } }),
|
|
33
|
+
tokenHash: createTextField({
|
|
34
|
+
required: true,
|
|
35
|
+
maxLength: 64,
|
|
36
|
+
access: { write: access.privileged },
|
|
37
|
+
}),
|
|
38
|
+
prefix: createTextField({
|
|
39
|
+
required: true,
|
|
40
|
+
maxLength: 16,
|
|
41
|
+
access: { write: access.privileged },
|
|
42
|
+
}),
|
|
43
|
+
// JSON-encoded string[] of granted scope names — mirrors the roles-column
|
|
44
|
+
// convention (parseRoles-style text); the resolver JSON.parses it.
|
|
45
|
+
scopes: createTextField({ required: true, access: { write: access.privileged } }),
|
|
46
|
+
createdAt: createTimestampField({ required: true, access: { write: access.privileged } }),
|
|
47
|
+
expiresAt: createTimestampField({ access: { write: access.privileged } }),
|
|
48
|
+
revokedAt: createTimestampField({ access: { write: access.privileged } }),
|
|
49
|
+
},
|
|
50
|
+
indexes: [{ unique: true, columns: ["tokenHash"], name: "read_api_tokens_hash_unique" }],
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// buildEntityTableMeta (not buildEntityTable): this is a direct-write store, so
|
|
54
|
+
// the table must be a WritableTable (post ES-write-brand #742) — same as
|
|
55
|
+
// sessions' userSessionTable. buildEntityTable is branded executor-only.
|
|
56
|
+
export const apiTokenTable = buildEntityTableMeta("api-token", apiTokenEntity);
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// Pure scope helpers + types — client-marked so the web screen (web/) may import
|
|
3
|
+
// parseGrant without pulling the feature's server runtime barrel.
|
|
4
|
+
//
|
|
5
|
+
// App-declared scopes — two axes, like GitHub fine-grained PATs: WHICH API
|
|
6
|
+
// (the domain, keyed here) × the permission LEVEL (read vs read+write). Each
|
|
7
|
+
// domain declares its read-QN globs and (optionally) its write-QN globs. A
|
|
8
|
+
// domain may span features (e.g. a "miete" domain granting ledger + folders).
|
|
9
|
+
export type PatScopeDef = {
|
|
10
|
+
readonly label: string;
|
|
11
|
+
readonly read: readonly string[];
|
|
12
|
+
// Omit for a read-only domain — the UI then offers only "no access" / "read".
|
|
13
|
+
readonly write?: readonly string[];
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type PatScopeConfig = Readonly<Record<string, PatScopeDef>>;
|
|
17
|
+
|
|
18
|
+
export type PatLevel = "read" | "write";
|
|
19
|
+
|
|
20
|
+
// A granted scope is the string "<domain>:<level>" (e.g. "credit:write"). The
|
|
21
|
+
// domain key never contains a colon, so split on the LAST one.
|
|
22
|
+
export function parseGrant(grant: string): { domain: string; level: string } | null {
|
|
23
|
+
const idx = grant.lastIndexOf(":");
|
|
24
|
+
if (idx <= 0) return null;
|
|
25
|
+
return { domain: grant.slice(0, idx), level: grant.slice(idx + 1) };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Expand granted "<domain>:<level>" entries into the union of QN globs: read
|
|
29
|
+
// always grants the read QNs; write additionally grants the write QNs. Unknown
|
|
30
|
+
// domains contribute nothing (fail-closed — a scope dropped from config after a
|
|
31
|
+
// token was minted silently loses that capability).
|
|
32
|
+
export function expandScopes(config: PatScopeConfig, granted: readonly string[]): string[] {
|
|
33
|
+
const out = new Set<string>();
|
|
34
|
+
for (const grant of granted) {
|
|
35
|
+
const parsed = parseGrant(grant);
|
|
36
|
+
if (!parsed) continue;
|
|
37
|
+
const def = config[parsed.domain];
|
|
38
|
+
if (!def) continue;
|
|
39
|
+
for (const q of def.read) out.add(q);
|
|
40
|
+
if (parsed.level === "write") for (const q of def.write ?? []) out.add(q);
|
|
41
|
+
}
|
|
42
|
+
return [...out];
|
|
43
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// Client-feature factory for personal-access-tokens. Ships the PatTokensScreen
|
|
3
|
+
// (mapped to the "api-tokens" screen id) + default translations. Apps mount it
|
|
4
|
+
// in createKumikoApp({ clientFeatures: [personalAccessTokensClient()] }); the
|
|
5
|
+
// server registers the screen dormant (r.screen) and the app places it via
|
|
6
|
+
// r.nav.
|
|
7
|
+
|
|
8
|
+
import { mergeTranslations, type TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
|
|
9
|
+
import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
|
|
10
|
+
import { PAT_FEATURE, PAT_SCREEN_ID } from "../constants";
|
|
11
|
+
import { defaultTranslations } from "./i18n";
|
|
12
|
+
import { PatTokensScreen } from "./pat-tokens-screen";
|
|
13
|
+
|
|
14
|
+
export type PersonalAccessTokensClientOptions = {
|
|
15
|
+
/** Key-wise overrides over the default bundles (de/en). */
|
|
16
|
+
readonly translations?: TranslationsByLocale;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function personalAccessTokensClient(
|
|
20
|
+
options?: PersonalAccessTokensClientOptions,
|
|
21
|
+
): ClientFeatureDefinition {
|
|
22
|
+
return {
|
|
23
|
+
name: PAT_FEATURE,
|
|
24
|
+
translations: mergeTranslations(defaultTranslations, options?.translations ?? {}),
|
|
25
|
+
components: {
|
|
26
|
+
[PAT_SCREEN_ID]: PatTokensScreen,
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
|
|
3
|
+
|
|
4
|
+
export const defaultTranslations: TranslationsByLocale = {
|
|
5
|
+
de: {
|
|
6
|
+
"pat.title": "Personal Access Tokens",
|
|
7
|
+
"pat.create.title": "Neuen Token erstellen",
|
|
8
|
+
"pat.create.subtitle":
|
|
9
|
+
"Wähle Berechtigungen und eine Gültigkeit. Der Token wird nur einmal angezeigt.",
|
|
10
|
+
"pat.create.name": "Name",
|
|
11
|
+
"pat.create.namePlaceholder": "z. B. CLI-Automatisierung",
|
|
12
|
+
"pat.create.scopes": "Zugriff pro Bereich",
|
|
13
|
+
"pat.level.none": "Kein Zugriff",
|
|
14
|
+
"pat.level.read": "Lesen",
|
|
15
|
+
"pat.level.write": "Lesen & Schreiben",
|
|
16
|
+
"pat.create.expiry": "Gültig bis",
|
|
17
|
+
"pat.create.submit": "Token erstellen",
|
|
18
|
+
"pat.create.needName": "Bitte einen Namen vergeben.",
|
|
19
|
+
"pat.create.needScope": "Bitte mindestens eine Berechtigung wählen.",
|
|
20
|
+
"pat.expiry.30d": "30 Tage",
|
|
21
|
+
"pat.expiry.90d": "90 Tage",
|
|
22
|
+
"pat.expiry.1y": "1 Jahr",
|
|
23
|
+
"pat.expiry.never": "Unbegrenzt",
|
|
24
|
+
"pat.created.hint":
|
|
25
|
+
"Kopiere diesen Token jetzt — er wird aus Sicherheitsgründen nur dieses eine Mal angezeigt.",
|
|
26
|
+
"pat.created.copy": "Kopieren",
|
|
27
|
+
"pat.created.copied": "Kopiert ✓",
|
|
28
|
+
"pat.created.dismiss": "Fertig",
|
|
29
|
+
"pat.list.title": "Deine Tokens",
|
|
30
|
+
"pat.list.empty": "Noch keine Tokens.",
|
|
31
|
+
"pat.list.revoke": "Widerrufen",
|
|
32
|
+
"pat.list.revoked": "Widerrufen",
|
|
33
|
+
"pat.list.validUntil": "Gültig bis {date}",
|
|
34
|
+
"pat.list.neverExpires": "Unbegrenzt gültig",
|
|
35
|
+
"pat.list.created": "Erstellt {date}",
|
|
36
|
+
"pat.error.generic": "Aktion fehlgeschlagen.",
|
|
37
|
+
},
|
|
38
|
+
en: {
|
|
39
|
+
"pat.title": "Personal Access Tokens",
|
|
40
|
+
"pat.create.title": "Create a new token",
|
|
41
|
+
"pat.create.subtitle": "Pick permissions and an expiration. The token is shown only once.",
|
|
42
|
+
"pat.create.name": "Name",
|
|
43
|
+
"pat.create.namePlaceholder": "e.g. CLI automation",
|
|
44
|
+
"pat.create.scopes": "Per-API access",
|
|
45
|
+
"pat.level.none": "No access",
|
|
46
|
+
"pat.level.read": "Read",
|
|
47
|
+
"pat.level.write": "Read & write",
|
|
48
|
+
"pat.create.expiry": "Expiration",
|
|
49
|
+
"pat.create.submit": "Create token",
|
|
50
|
+
"pat.create.needName": "Please enter a name.",
|
|
51
|
+
"pat.create.needScope": "Please select at least one permission.",
|
|
52
|
+
"pat.expiry.30d": "30 days",
|
|
53
|
+
"pat.expiry.90d": "90 days",
|
|
54
|
+
"pat.expiry.1y": "1 year",
|
|
55
|
+
"pat.expiry.never": "No expiration",
|
|
56
|
+
"pat.created.hint": "Copy this token now — for security it is shown only this once.",
|
|
57
|
+
"pat.created.copy": "Copy",
|
|
58
|
+
"pat.created.copied": "Copied ✓",
|
|
59
|
+
"pat.created.dismiss": "Done",
|
|
60
|
+
"pat.list.title": "Your tokens",
|
|
61
|
+
"pat.list.empty": "No tokens yet.",
|
|
62
|
+
"pat.list.revoke": "Revoke",
|
|
63
|
+
"pat.list.revoked": "Revoked",
|
|
64
|
+
"pat.list.validUntil": "Valid until {date}",
|
|
65
|
+
"pat.list.neverExpires": "Never expires",
|
|
66
|
+
"pat.list.created": "Created {date}",
|
|
67
|
+
"pat.error.generic": "Action failed.",
|
|
68
|
+
},
|
|
69
|
+
};
|