okengine 0.15.2 → 0.16.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 +1 -1
- package/site/content/docs/elements/flow.mdx +6 -0
- package/site/content/docs/elements/gate.mdx +55 -0
- package/site/content/docs/elements/store.mdx +13 -15
- package/site/content/docs/elements/vault.mdx +3 -0
- package/site/content/docs/reference/client.mdx +3 -0
- package/site/content/docs/reference/fx.mdx +29 -18
- package/site/content/docs/reference/security.md +1 -1
- package/src/auth/api-key-sql.test.ts +47 -0
- package/src/auth/api-key-sql.ts +254 -0
- package/src/auth/api-keys.ts +273 -14
- package/src/auth/auth.test.ts +56 -0
- package/src/auth/config.ts +5 -5
- package/src/auth/index.ts +25 -1
- package/src/auth/schema.ts +8 -0
- package/src/cli/attach-host-console.test.ts +37 -0
- package/src/cli/attach-host-console.ts +6 -0
- package/src/compiler/effects-infer.ts +17 -2
- package/src/console/server/access.test.ts +116 -8
- package/src/console/server/access.ts +70 -23
- package/src/console/server/app.ts +1 -0
- package/src/console/server/dev-identities.test.ts +127 -0
- package/src/console/server/dev-identities.ts +229 -0
- package/src/console/server/flows-invoke.test.ts +21 -4
- package/src/console/server/flows.ts +113 -58
- package/src/console/server/gates.test.ts +24 -0
- package/src/console/server/gates.ts +8 -21
- package/src/console/server/index.ts +1 -0
- package/src/console/server/live.test.ts +26 -1
- package/src/console/server/live.ts +4 -2
- package/src/console/server/operator-db.ts +2 -0
- package/src/console/server/state.ts +29 -55
- package/src/console/ui-next/dist/assets/access-page-CAGHrA9H.js +4 -0
- package/src/console/ui-next/dist/assets/{agent-disclosure-C0X1fbWF.js → agent-disclosure-BP0Y0Sux.js} +1 -1
- package/src/console/ui-next/dist/assets/cache-glyph-B5X-NM0-.js +1 -0
- package/src/console/ui-next/dist/assets/call-pii-button-ChAUiLo9.js +1 -0
- package/src/console/ui-next/dist/assets/collapsible-DGnOM2ph.js +1 -0
- package/src/console/ui-next/dist/assets/copy-inline-button-DBHhgHKP.js +1 -0
- package/src/console/ui-next/dist/assets/detail-header-DhHM1iaZ.js +1 -0
- package/src/console/ui-next/dist/assets/dropdown-menu-_NjTEo5_.js +1 -0
- package/src/console/ui-next/dist/assets/element-icons-BVXtRyd3.js +1 -0
- package/src/console/ui-next/dist/assets/explorer-empty-2uIhBu0_.js +1 -0
- package/src/console/ui-next/dist/assets/flows-page-B-OUtiAu.js +1 -0
- package/src/console/ui-next/dist/assets/{highlighted-json-CS_O8L-r.js → highlighted-json-Awq7gYdu.js} +1 -1
- package/src/console/ui-next/dist/assets/http-method-_UHM2ODJ.js +1 -0
- package/src/console/ui-next/dist/assets/index-CGoZkILK.js +66 -0
- package/src/console/ui-next/dist/assets/index-Ck88Jmv8.css +2 -0
- package/src/console/ui-next/dist/assets/link-BX6Vqztd.js +1 -0
- package/src/console/ui-next/dist/assets/observability-page-BDyXalNR.js +4 -0
- package/src/console/ui-next/dist/assets/replica-lag-DKRrbvdo.js +18 -0
- package/src/console/ui-next/dist/assets/request-meta-DV0ywz7t.js +1 -0
- package/src/console/ui-next/dist/assets/shortcut-JQIZlWfm.js +1 -0
- package/src/console/ui-next/dist/assets/shortcut-keys-DKxNTe_m.js +1 -0
- package/src/console/ui-next/dist/assets/skeleton-D-czQJT6.js +1 -0
- package/src/console/ui-next/dist/assets/store-page-Xh8Kn3rx.js +41 -0
- package/src/console/ui-next/dist/assets/trace-detail-sheet-B09O8rA6.js +2 -0
- package/src/console/ui-next/dist/assets/tree-expand-toggle-DkOXA12R.js +54 -0
- package/src/console/ui-next/dist/assets/units-page-Dpk40kOQ.js +1 -0
- package/src/console/ui-next/dist/assets/{use-vault-list-BLbZhzyF.js → use-vault-list-uk4WVboC.js} +1 -1
- package/src/console/ui-next/dist/assets/useMutation-B8EO02Ej.js +1 -0
- package/src/console/ui-next/dist/assets/useRender-BE2A9BWC.js +1 -0
- package/src/console/ui-next/dist/assets/vault-page-B1dB9Ft0.js +2 -0
- package/src/console/ui-next/dist/index.html +11 -9
- package/src/console/ui-next/index.html +3 -3
- package/src/console/ui-next/src/client.ts +167 -0
- package/src/console/ui-next/src/components/shell/app-sidebar.tsx +2 -0
- package/src/console/ui-next/src/components/shell/command-items.test.ts +1 -0
- package/src/console/ui-next/src/components/shell/command-items.ts +8 -0
- package/src/console/ui-next/src/components/shell/shell-layout.tsx +2 -1
- package/src/console/ui-next/src/components/ui/sheet-form.tsx +8 -3
- package/src/console/ui-next/src/features/access/access-page.tsx +347 -0
- package/src/console/ui-next/src/features/access/data/use-access.ts +23 -0
- package/src/console/ui-next/src/features/access/detail/access-detail.tsx +552 -0
- package/src/console/ui-next/src/features/access/explorer/access-list.tsx +125 -0
- package/src/console/ui-next/src/features/access/lib/format-when.test.ts +139 -0
- package/src/console/ui-next/src/features/access/lib/format-when.ts +296 -0
- package/src/console/ui-next/src/features/access/lib/scope-groups.test.ts +34 -0
- package/src/console/ui-next/src/features/access/lib/scope-groups.ts +40 -0
- package/src/console/ui-next/src/features/access/sheets/access-allow-fields.tsx +236 -0
- package/src/console/ui-next/src/features/access/sheets/access-create-sheet.tsx +316 -0
- package/src/console/ui-next/src/features/access/sheets/access-edit-sheet.tsx +240 -0
- package/src/console/ui-next/src/features/access/sheets/access-expiry-fields.tsx +66 -0
- package/src/console/ui-next/src/features/access/sheets/access-refresh-sheet.tsx +104 -0
- package/src/console/ui-next/src/features/access/sheets/access-scope-field.tsx +115 -0
- package/src/console/ui-next/src/features/access/sheets/access-secret-sheet.tsx +89 -0
- package/src/console/ui-next/src/features/access/sheets/access-sheet-search.tsx +49 -0
- package/src/console/ui-next/src/features/access/state/access-selection.ts +84 -0
- package/src/console/ui-next/src/features/auth/auth-redirect.test.ts +5 -0
- package/src/console/ui-next/src/features/auth/auth-redirect.ts +18 -2
- package/src/console/ui-next/src/features/not-found/not-found-page.tsx +1 -1
- package/src/console/ui-next/src/lib/document-meta.test.ts +1 -0
- package/src/console/ui-next/src/lib/document-meta.ts +2 -1
- package/src/console/ui-next/src/lib/last-module-search.test.ts +1 -0
- package/src/console/ui-next/src/lib/last-module-search.ts +2 -1
- package/src/console/ui-next/src/lib/shortcut.test.ts +2 -0
- package/src/console/ui-next/src/lib/shortcut.ts +3 -0
- package/src/console/ui-next/src/router.tsx +10 -1
- package/src/console/ui-next/ui-next-seed-app-schema.ts +88 -37
- package/src/console/ui-next/ui-next-seed-store.ts +43 -15
- package/src/console/xss.gate.test.ts +1 -0
- package/src/elements/gate/declare.ts +2 -0
- package/src/elements/store/cache.ts +1 -1
- package/src/elements/store/runtime.ts +14 -0
- package/src/elements/store/schema-decl.ts +99 -41
- package/src/elements/store/sql-select.types.test.ts +71 -0
- package/src/elements/store/sql-session.ts +54 -32
- package/src/elements/store.ts +3 -0
- package/src/index.ts +1 -0
- package/src/kernel/api-key-host-persist.test.ts +143 -0
- package/src/kernel/app.ts +14 -2
- package/src/kernel/auth-resolve.ts +10 -5
- package/src/kernel/boot.test.ts +4 -4
- package/src/kernel/fx-auth-keys.test.ts +88 -0
- package/src/kernel/fx-auth-keys.ts +198 -0
- package/src/kernel/fx.test.ts +17 -1
- package/src/kernel/fx.ts +36 -18
- package/src/kernel/pipeline-api-key.test.ts +192 -7
- package/src/kernel/pipeline.ts +4 -2
- package/src/manifest/types.ts +9 -3
- package/src/release/build-lib.ts +1 -0
- package/src/console/ui-next/dist/assets/cache-glyph-F1FI122b.js +0 -1
- package/src/console/ui-next/dist/assets/call-pii-button-DEDCl_j3.js +0 -1
- package/src/console/ui-next/dist/assets/collapsible-LPqGvfoz.js +0 -1
- package/src/console/ui-next/dist/assets/confirm-sheet-3ptDzXbz.js +0 -1
- package/src/console/ui-next/dist/assets/explorer-empty-BvYrygyO.js +0 -1
- package/src/console/ui-next/dist/assets/flows-page-Dluo1Bd7.js +0 -1
- package/src/console/ui-next/dist/assets/http-method--sWDdXSB.js +0 -1
- package/src/console/ui-next/dist/assets/index-Ca3HZMVq.js +0 -66
- package/src/console/ui-next/dist/assets/index-UtTDRQpS.css +0 -2
- package/src/console/ui-next/dist/assets/link-COeyggt-.js +0 -1
- package/src/console/ui-next/dist/assets/observability-page-HK9-BO8z.js +0 -4
- package/src/console/ui-next/dist/assets/replica-lag-DBIFFf7d.js +0 -18
- package/src/console/ui-next/dist/assets/sheet-form-Dnwa5oOR.js +0 -1
- package/src/console/ui-next/dist/assets/shortcut-Cgzuv4k1.js +0 -1
- package/src/console/ui-next/dist/assets/shortcut-keys-CoqwEIi0.js +0 -1
- package/src/console/ui-next/dist/assets/skeleton-BHcfDYcb.js +0 -1
- package/src/console/ui-next/dist/assets/store-page-BSohH3wM.js +0 -41
- package/src/console/ui-next/dist/assets/trace-detail-sheet-CFIGRnnA.js +0 -2
- package/src/console/ui-next/dist/assets/tree-expand-toggle-BgLl34w7.js +0 -54
- package/src/console/ui-next/dist/assets/units-page-B0cFE76A.js +0 -1
- package/src/console/ui-next/dist/assets/vault-page-B1SbYe10.js +0 -2
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
accessExpiresAt,
|
|
4
|
+
accessExpiryFromAt,
|
|
5
|
+
accessRefreshExpiry,
|
|
6
|
+
formatAccessDuration,
|
|
7
|
+
formatAccessExpiry,
|
|
8
|
+
formatAccessRateLimit,
|
|
9
|
+
formatAccessWhen,
|
|
10
|
+
classifyAccessAllowEntry,
|
|
11
|
+
parseAccessAllowlist,
|
|
12
|
+
parseAccessDurationMs,
|
|
13
|
+
parseAccessRateLimit,
|
|
14
|
+
parseAccessRateParts,
|
|
15
|
+
splitAccessRatePer,
|
|
16
|
+
} from "./format-when.ts";
|
|
17
|
+
|
|
18
|
+
const NOW = Date.parse("2026-08-21T17:42:00.000Z");
|
|
19
|
+
|
|
20
|
+
describe("formatAccessWhen", () => {
|
|
21
|
+
test("null is never", () => {
|
|
22
|
+
expect(formatAccessWhen(null, NOW)).toBe("never");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("sub-minute is just now", () => {
|
|
26
|
+
expect(formatAccessWhen(NOW - 12_000, NOW)).toBe("just now");
|
|
27
|
+
expect(formatAccessWhen(NOW + 12_000, NOW)).toBe("just now");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("past ages", () => {
|
|
31
|
+
expect(formatAccessWhen(NOW - 5 * 60_000, NOW)).toBe("5m ago");
|
|
32
|
+
expect(formatAccessWhen(NOW - 3 * 3_600_000, NOW)).toBe("3h ago");
|
|
33
|
+
expect(formatAccessWhen(NOW - 2 * 86_400_000, NOW)).toBe("2d ago");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("future remaining", () => {
|
|
37
|
+
expect(formatAccessWhen(NOW + 30 * 86_400_000, NOW)).toBe("in 30d");
|
|
38
|
+
expect(formatAccessWhen(NOW + 90 * 86_400_000, NOW)).toBe("in 90d");
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("formatAccessExpiry", () => {
|
|
43
|
+
test("null is never", () => {
|
|
44
|
+
expect(formatAccessExpiry(null, NOW)).toBe("never");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("past is expired, not an age", () => {
|
|
48
|
+
expect(formatAccessExpiry(NOW - 5 * 60_000, NOW)).toBe("expired");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("future is remaining", () => {
|
|
52
|
+
expect(formatAccessExpiry(NOW + 89 * 86_400_000, NOW)).toBe("in 89d");
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("accessExpiresAt", () => {
|
|
57
|
+
test("presets", () => {
|
|
58
|
+
expect(accessExpiresAt("never", NOW)).toBeNull();
|
|
59
|
+
expect(accessExpiresAt("30d", NOW)).toBe(NOW + 30 * 86_400_000);
|
|
60
|
+
expect(accessExpiresAt("90d", NOW)).toBe(NOW + 90 * 86_400_000);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("custom duration", () => {
|
|
64
|
+
expect(accessExpiresAt("custom", NOW, "7d")).toBe(NOW + 7 * 86_400_000);
|
|
65
|
+
expect(accessExpiresAt("custom", NOW, "12h")).toBe(NOW + 12 * 3_600_000);
|
|
66
|
+
expect(accessExpiresAt("custom", NOW, "nope")).toBeNull();
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("parseAccessDurationMs", () => {
|
|
71
|
+
test("clock grammar", () => {
|
|
72
|
+
expect(parseAccessDurationMs("7d")).toBe(7 * 86_400_000);
|
|
73
|
+
expect(parseAccessDurationMs("30m")).toBe(30 * 60_000);
|
|
74
|
+
expect(parseAccessDurationMs("")).toBe(0);
|
|
75
|
+
expect(parseAccessDurationMs("90")).toBe(0);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("accessExpiryFromAt", () => {
|
|
80
|
+
test("reconstructs presets and custom remainder", () => {
|
|
81
|
+
expect(accessExpiryFromAt(null, NOW)).toEqual({ choice: "never", custom: "" });
|
|
82
|
+
expect(accessExpiryFromAt(NOW + 30 * 86_400_000, NOW)).toEqual({ choice: "30d", custom: "" });
|
|
83
|
+
expect(accessExpiryFromAt(NOW + 7 * 86_400_000, NOW)).toEqual({
|
|
84
|
+
choice: "custom",
|
|
85
|
+
custom: "7d",
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("accessRefreshExpiry", () => {
|
|
91
|
+
test("never and expired default to 90d", () => {
|
|
92
|
+
expect(accessRefreshExpiry(null, NOW)).toEqual({ choice: "90d", custom: "" });
|
|
93
|
+
expect(accessRefreshExpiry(NOW - 1, NOW)).toEqual({ choice: "90d", custom: "" });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("snaps remaining life so 89d renews as 90d", () => {
|
|
97
|
+
expect(accessRefreshExpiry(NOW + 89 * 86_400_000, NOW)).toEqual({
|
|
98
|
+
choice: "90d",
|
|
99
|
+
custom: "",
|
|
100
|
+
});
|
|
101
|
+
expect(accessRefreshExpiry(NOW + 23 * 86_400_000, NOW)).toEqual({
|
|
102
|
+
choice: "30d",
|
|
103
|
+
custom: "",
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("keeps custom windows that are not near a preset", () => {
|
|
108
|
+
expect(accessRefreshExpiry(NOW + 7 * 86_400_000, NOW)).toEqual({
|
|
109
|
+
choice: "custom",
|
|
110
|
+
custom: "7d",
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("rate and allowlist", () => {
|
|
116
|
+
test("parse and format", () => {
|
|
117
|
+
expect(parseAccessRateLimit("")).toBeNull();
|
|
118
|
+
expect(parseAccessRateLimit("60 / 1m")).toEqual({ max: 60, per: "1m" });
|
|
119
|
+
expect(parseAccessRateLimit("nope")).toBeUndefined();
|
|
120
|
+
expect(formatAccessRateLimit({ max: 60, per: "1m" })).toBe("60 / 1m");
|
|
121
|
+
expect(parseAccessAllowlist("203.0.113.4, 198.51.100.7")).toEqual([
|
|
122
|
+
"203.0.113.4",
|
|
123
|
+
"198.51.100.7",
|
|
124
|
+
]);
|
|
125
|
+
expect(parseAccessAllowlist("https://ci.example.com/")).toEqual(["ci.example.com"]);
|
|
126
|
+
expect(parseAccessAllowlist("google.com, sdfghjfdsd, not a host")).toEqual(["google.com"]);
|
|
127
|
+
expect(parseAccessAllowlist("localhost")).toEqual(["localhost"]);
|
|
128
|
+
expect(parseAccessAllowlist("[::1]")).toEqual(["::1"]);
|
|
129
|
+
expect(classifyAccessAllowEntry("203.0.113.4")).toBe("ip");
|
|
130
|
+
expect(classifyAccessAllowEntry("ci.example.com")).toBe("host");
|
|
131
|
+
expect(classifyAccessAllowEntry("sdfghjfdsd")).toBeNull();
|
|
132
|
+
expect(formatAccessDuration(12 * 3_600_000)).toBe("12h");
|
|
133
|
+
expect(parseAccessRateParts("", "", "m")).toBeNull();
|
|
134
|
+
expect(parseAccessRateParts("90", "", "m")).toBeUndefined();
|
|
135
|
+
expect(parseAccessRateParts("90", "1", "m")).toEqual({ max: 90, per: "1m" });
|
|
136
|
+
expect(splitAccessRatePer("90s")).toEqual({ count: "90", unit: "s" });
|
|
137
|
+
expect(splitAccessRatePer("")).toEqual({ count: "", unit: "m" });
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Relative timestamps for Access keys (create / last used / expiry).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Age or remaining time of an Access timestamp, or `never` when null.
|
|
7
|
+
*
|
|
8
|
+
* Past → `5m ago`. Future → `in 30d`. Sub-minute → `just now`.
|
|
9
|
+
*
|
|
10
|
+
* @param at - Epoch ms, or null
|
|
11
|
+
* @param now - Clock
|
|
12
|
+
*/
|
|
13
|
+
export function formatAccessWhen(at: number | null, now: number): string {
|
|
14
|
+
if (at == null) return "never";
|
|
15
|
+
const delta = at - now;
|
|
16
|
+
if (Math.abs(delta) < 60_000) return "just now";
|
|
17
|
+
const abs = Math.abs(delta);
|
|
18
|
+
const unit = unitLabel(abs);
|
|
19
|
+
return delta < 0 ? `${unit} ago` : `in ${unit}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Remaining life of an expiry, or `expired` once the clock passes it.
|
|
24
|
+
*
|
|
25
|
+
* @param at - Epoch ms, or null
|
|
26
|
+
* @param now - Clock
|
|
27
|
+
*/
|
|
28
|
+
export function formatAccessExpiry(at: number | null, now: number): string {
|
|
29
|
+
if (at == null) return "never";
|
|
30
|
+
if (at <= now) return "expired";
|
|
31
|
+
return formatAccessWhen(at, now);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function unitLabel(abs: number): string {
|
|
35
|
+
if (abs < 3_600_000) return `${Math.floor(abs / 60_000)}m`;
|
|
36
|
+
if (abs < 86_400_000) return `${Math.floor(abs / 3_600_000)}h`;
|
|
37
|
+
const days = Math.floor(abs / 86_400_000);
|
|
38
|
+
if (days < 100) return `${days}d`;
|
|
39
|
+
return `${Math.floor(days / 30)}mo`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const DAY_MS = 86_400_000;
|
|
43
|
+
|
|
44
|
+
/** Expiry presets on the create sheet. */
|
|
45
|
+
export type AccessExpiryChoice = "never" | "30d" | "90d" | "custom";
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Parse a Clock duration (`7d`, `12h`, `30m`). Returns `0` when invalid.
|
|
49
|
+
*
|
|
50
|
+
* @param raw - Duration string
|
|
51
|
+
*/
|
|
52
|
+
export function parseAccessDurationMs(raw: string): number {
|
|
53
|
+
const match = /^(\d+)(ms|s|m|h|d)$/.exec(raw.trim());
|
|
54
|
+
if (!match) return 0;
|
|
55
|
+
const n = Number(match[1]);
|
|
56
|
+
switch (match[2]) {
|
|
57
|
+
case "ms":
|
|
58
|
+
return n;
|
|
59
|
+
case "s":
|
|
60
|
+
return n * 1000;
|
|
61
|
+
case "m":
|
|
62
|
+
return n * 60_000;
|
|
63
|
+
case "h":
|
|
64
|
+
return n * 3_600_000;
|
|
65
|
+
case "d":
|
|
66
|
+
return n * DAY_MS;
|
|
67
|
+
default:
|
|
68
|
+
return 0;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Epoch ms for a create-sheet expiry choice.
|
|
74
|
+
*
|
|
75
|
+
* @param choice - Preset or custom
|
|
76
|
+
* @param now - Clock
|
|
77
|
+
* @param custom - Duration when {@link AccessExpiryChoice} is `custom`
|
|
78
|
+
*/
|
|
79
|
+
export function accessExpiresAt(
|
|
80
|
+
choice: AccessExpiryChoice,
|
|
81
|
+
now: number,
|
|
82
|
+
custom = "",
|
|
83
|
+
): number | null {
|
|
84
|
+
if (choice === "never") return null;
|
|
85
|
+
if (choice === "30d") return now + 30 * DAY_MS;
|
|
86
|
+
if (choice === "90d") return now + 90 * DAY_MS;
|
|
87
|
+
const ms = parseAccessDurationMs(custom);
|
|
88
|
+
return ms > 0 ? now + ms : null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const HOUR_MS = 3_600_000;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Reconstruct an expiry choice from a stored timestamp.
|
|
95
|
+
*
|
|
96
|
+
* @param expiresAt - Epoch ms, or null
|
|
97
|
+
* @param now - Clock
|
|
98
|
+
*/
|
|
99
|
+
export function accessExpiryFromAt(
|
|
100
|
+
expiresAt: number | null,
|
|
101
|
+
now: number,
|
|
102
|
+
): { readonly choice: AccessExpiryChoice; readonly custom: string } {
|
|
103
|
+
if (expiresAt == null) return { choice: "never", custom: "" };
|
|
104
|
+
const remaining = expiresAt - now;
|
|
105
|
+
if (remaining <= 0) return { choice: "never", custom: "" };
|
|
106
|
+
if (Math.abs(remaining - 30 * DAY_MS) < HOUR_MS) return { choice: "30d", custom: "" };
|
|
107
|
+
if (Math.abs(remaining - 90 * DAY_MS) < HOUR_MS) return { choice: "90d", custom: "" };
|
|
108
|
+
return { choice: "custom", custom: formatAccessDuration(remaining) };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Snap remaining life to 30d / 90d so weekly refresh does not shrink the window. */
|
|
112
|
+
const REFRESH_SNAP_MS = 7 * DAY_MS;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Seed a Refresh sheet: snap remaining life to 30d / 90d. Expired or never
|
|
116
|
+
* defaults to 90d. The secret is unchanged — only `expiresAt` moves.
|
|
117
|
+
*
|
|
118
|
+
* @param expiresAt - Epoch ms, or null
|
|
119
|
+
* @param now - Clock
|
|
120
|
+
*/
|
|
121
|
+
export function accessRefreshExpiry(
|
|
122
|
+
expiresAt: number | null,
|
|
123
|
+
now: number,
|
|
124
|
+
): { readonly choice: AccessExpiryChoice; readonly custom: string } {
|
|
125
|
+
if (expiresAt == null || expiresAt <= now) return { choice: "90d", custom: "" };
|
|
126
|
+
const remaining = expiresAt - now;
|
|
127
|
+
if (Math.abs(remaining - 30 * DAY_MS) <= REFRESH_SNAP_MS) return { choice: "30d", custom: "" };
|
|
128
|
+
if (Math.abs(remaining - 90 * DAY_MS) <= REFRESH_SNAP_MS) return { choice: "90d", custom: "" };
|
|
129
|
+
return { choice: "custom", custom: formatAccessDuration(remaining) };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Clock duration for a remaining span (`7d` · `12h` · `30m`).
|
|
134
|
+
*
|
|
135
|
+
* @param ms - Remaining milliseconds
|
|
136
|
+
*/
|
|
137
|
+
export function formatAccessDuration(ms: number): string {
|
|
138
|
+
if (ms <= 0) return "";
|
|
139
|
+
if (ms >= DAY_MS && ms % DAY_MS === 0) return `${ms / DAY_MS}d`;
|
|
140
|
+
if (ms >= HOUR_MS && ms % HOUR_MS === 0) return `${ms / HOUR_MS}h`;
|
|
141
|
+
if (ms >= 60_000 && ms % 60_000 === 0) return `${ms / 60_000}m`;
|
|
142
|
+
if (ms >= DAY_MS) return `${Math.max(1, Math.round(ms / DAY_MS))}d`;
|
|
143
|
+
if (ms >= HOUR_MS) return `${Math.max(1, Math.round(ms / HOUR_MS))}h`;
|
|
144
|
+
return `${Math.max(1, Math.round(ms / 60_000))}m`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Parse `60 / 1m`. Empty is no limit.
|
|
149
|
+
*
|
|
150
|
+
* @param raw - Inspector-style rate
|
|
151
|
+
*/
|
|
152
|
+
export function parseAccessRateLimit(raw: string): { max: number; per: string } | null | undefined {
|
|
153
|
+
const t = raw.trim();
|
|
154
|
+
if (t.length === 0) return null;
|
|
155
|
+
const match = /^(\d+)\s*\/\s*(\d+(?:ms|s|m|h|d))$/.exec(t);
|
|
156
|
+
if (!match) return undefined;
|
|
157
|
+
return { max: Number(match[1]), per: match[2]! };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Format a stored rate for the edit field.
|
|
162
|
+
*
|
|
163
|
+
* @param rate - Stored limit
|
|
164
|
+
*/
|
|
165
|
+
export function formatAccessRateLimit(rate: { max: number; per: string } | null): string {
|
|
166
|
+
return rate ? `${rate.max} / ${rate.per}` : "";
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Clock units for the rate window. */
|
|
170
|
+
export const ACCESS_RATE_UNITS = ["s", "m", "h", "d", "ms"] as const;
|
|
171
|
+
|
|
172
|
+
/** One {@link ACCESS_RATE_UNITS} token. */
|
|
173
|
+
export type AccessRateUnit = (typeof ACCESS_RATE_UNITS)[number];
|
|
174
|
+
|
|
175
|
+
/** Default window unit — minutes. */
|
|
176
|
+
export const DEFAULT_ACCESS_RATE_UNIT: AccessRateUnit = "m";
|
|
177
|
+
|
|
178
|
+
/** Hover labels for {@link ACCESS_RATE_UNITS}. */
|
|
179
|
+
export const ACCESS_RATE_UNIT_LABELS: Readonly<Record<AccessRateUnit, string>> = {
|
|
180
|
+
s: "Seconds",
|
|
181
|
+
m: "Minutes",
|
|
182
|
+
h: "Hours",
|
|
183
|
+
d: "Days",
|
|
184
|
+
ms: "Milliseconds",
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Parse max + window + unit. Both count fields empty is none.
|
|
189
|
+
*
|
|
190
|
+
* @param maxRaw - Hit count
|
|
191
|
+
* @param countRaw - Window count
|
|
192
|
+
* @param unit - Window unit
|
|
193
|
+
*/
|
|
194
|
+
export function parseAccessRateParts(
|
|
195
|
+
maxRaw: string,
|
|
196
|
+
countRaw: string,
|
|
197
|
+
unit: AccessRateUnit,
|
|
198
|
+
): { max: number; per: string } | null | undefined {
|
|
199
|
+
const maxT = maxRaw.trim();
|
|
200
|
+
const countT = countRaw.trim();
|
|
201
|
+
if (maxT.length === 0 && countT.length === 0) return null;
|
|
202
|
+
if (maxT.length === 0 || countT.length === 0) return undefined;
|
|
203
|
+
if (!/^\d+$/.test(maxT) || !/^\d+$/.test(countT)) return undefined;
|
|
204
|
+
const max = Number(maxT);
|
|
205
|
+
const count = Number(countT);
|
|
206
|
+
if (max <= 0 || count <= 0) return undefined;
|
|
207
|
+
const per = `${count}${unit}`;
|
|
208
|
+
if (parseAccessDurationMs(per) <= 0) return undefined;
|
|
209
|
+
return { max, per };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Split a stored `1m` window into count + unit.
|
|
214
|
+
*
|
|
215
|
+
* @param per - Stored window
|
|
216
|
+
*/
|
|
217
|
+
export function splitAccessRatePer(per: string): {
|
|
218
|
+
readonly count: string;
|
|
219
|
+
readonly unit: AccessRateUnit;
|
|
220
|
+
} {
|
|
221
|
+
const match = /^(\d+)(ms|s|m|h|d)$/.exec(per.trim());
|
|
222
|
+
if (!match) return { count: "", unit: DEFAULT_ACCESS_RATE_UNIT };
|
|
223
|
+
return { count: match[1]!, unit: match[2] as AccessRateUnit };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Kind of a valid allow entry. */
|
|
227
|
+
export type AccessAllowKind = "ip" | "host";
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Classify a stored or draft allow entry. `null` is junk.
|
|
231
|
+
*
|
|
232
|
+
* @param raw - IP, hostname, or URL
|
|
233
|
+
*/
|
|
234
|
+
export function classifyAccessAllowEntry(raw: string): AccessAllowKind | null {
|
|
235
|
+
const part = normalizeAllowlistPart(raw);
|
|
236
|
+
if (part.length === 0) return null;
|
|
237
|
+
if (isAllowIp(part)) return "ip";
|
|
238
|
+
if (isAllowHost(part)) return "host";
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Split an allowlist field on commas or whitespace. Drops invalid parts.
|
|
244
|
+
*
|
|
245
|
+
* @param raw - Field value
|
|
246
|
+
*/
|
|
247
|
+
export function parseAccessAllowlist(raw: string): readonly string[] {
|
|
248
|
+
const seen = new Set<string>();
|
|
249
|
+
const next: string[] = [];
|
|
250
|
+
for (const chunk of raw.split(/[\s,]+/)) {
|
|
251
|
+
const part = normalizeAllowlistPart(chunk);
|
|
252
|
+
if (part.length === 0 || seen.has(part)) continue;
|
|
253
|
+
if (classifyAccessAllowEntry(part) == null) continue;
|
|
254
|
+
seen.add(part);
|
|
255
|
+
next.push(part);
|
|
256
|
+
}
|
|
257
|
+
return next;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function normalizeAllowlistPart(raw: string): string {
|
|
261
|
+
let part = raw.trim().toLowerCase();
|
|
262
|
+
part = part.replace(/^https?:\/\//, "");
|
|
263
|
+
if (part.startsWith("[") && part.includes("]")) {
|
|
264
|
+
const end = part.indexOf("]");
|
|
265
|
+
const inner = part.slice(1, end);
|
|
266
|
+
const rest = part.slice(end + 1);
|
|
267
|
+
part = rest.startsWith(":") || rest.startsWith("/") ? inner : `${inner}${rest}`;
|
|
268
|
+
}
|
|
269
|
+
const slash = part.indexOf("/");
|
|
270
|
+
if (slash >= 0) part = part.slice(0, slash);
|
|
271
|
+
if (part.endsWith(".")) part = part.slice(0, -1);
|
|
272
|
+
return part;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function isAllowIp(entry: string): boolean {
|
|
276
|
+
if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(entry)) {
|
|
277
|
+
return entry.split(".").every((octet) => Number(octet) <= 255);
|
|
278
|
+
}
|
|
279
|
+
if (!entry.includes(":")) return false;
|
|
280
|
+
if (!/^[0-9a-f:]+$/.test(entry)) return false;
|
|
281
|
+
if (entry.split("::").length > 2) return false;
|
|
282
|
+
return entry.split(":").filter((part) => part.length > 0).length >= 1;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function isAllowHost(entry: string): boolean {
|
|
286
|
+
if (entry === "localhost") return true;
|
|
287
|
+
if (entry.length > 253 || entry.includes(":")) return false;
|
|
288
|
+
const labels = entry.split(".");
|
|
289
|
+
if (labels.length < 2) return false;
|
|
290
|
+
return labels.every((label, index) => {
|
|
291
|
+
if (label.length === 0 || label.length > 63) return false;
|
|
292
|
+
if (!/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(label)) return false;
|
|
293
|
+
if (index === labels.length - 1 && !/^[a-z]{2,63}$/.test(label)) return false;
|
|
294
|
+
return true;
|
|
295
|
+
});
|
|
296
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { groupAccessScopes } from "./scope-groups.ts";
|
|
3
|
+
|
|
4
|
+
describe("groupAccessScopes", () => {
|
|
5
|
+
test("groups Module:Action by the first segment", () => {
|
|
6
|
+
expect(
|
|
7
|
+
groupAccessScopes([
|
|
8
|
+
"booking:create",
|
|
9
|
+
"comment:write",
|
|
10
|
+
"member",
|
|
11
|
+
"member:admin",
|
|
12
|
+
"console:flows:invoke-as",
|
|
13
|
+
]),
|
|
14
|
+
).toEqual([
|
|
15
|
+
{ group: "booking", items: [{ scope: "booking:create", action: "create" }] },
|
|
16
|
+
{ group: "comment", items: [{ scope: "comment:write", action: "write" }] },
|
|
17
|
+
{
|
|
18
|
+
group: "member",
|
|
19
|
+
items: [
|
|
20
|
+
{ scope: "member", action: "member" },
|
|
21
|
+
{ scope: "member:admin", action: "admin" },
|
|
22
|
+
],
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
group: "console",
|
|
26
|
+
items: [{ scope: "console:flows:invoke-as", action: "flows:invoke-as" }],
|
|
27
|
+
},
|
|
28
|
+
]);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("empty catalog", () => {
|
|
32
|
+
expect(groupAccessScopes([])).toEqual([]);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Group Access scopes by Module:Action prefix.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** One Module:Action (or bare) scope inside a group. */
|
|
6
|
+
export interface AccessScopeItem {
|
|
7
|
+
readonly scope: string;
|
|
8
|
+
readonly action: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Scopes that share a module prefix. */
|
|
12
|
+
export interface AccessScopeGroup {
|
|
13
|
+
readonly group: string;
|
|
14
|
+
readonly items: readonly AccessScopeItem[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Preserve catalog order; same-prefix scopes share a band.
|
|
19
|
+
* `member` and `member:admin` land under `member`. Nested colons
|
|
20
|
+
* (`console:flows:invoke-as`) stay under the first segment.
|
|
21
|
+
*
|
|
22
|
+
* @param scopes - Grantable or current scopes
|
|
23
|
+
*/
|
|
24
|
+
export function groupAccessScopes(scopes: readonly string[]): readonly AccessScopeGroup[] {
|
|
25
|
+
const order: string[] = [];
|
|
26
|
+
const map = new Map<string, AccessScopeItem[]>();
|
|
27
|
+
for (const scope of scopes) {
|
|
28
|
+
const i = scope.indexOf(":");
|
|
29
|
+
const group = i <= 0 ? scope : scope.slice(0, i);
|
|
30
|
+
const action = i <= 0 ? scope : scope.slice(i + 1);
|
|
31
|
+
const existing = map.get(group);
|
|
32
|
+
if (existing) {
|
|
33
|
+
existing.push({ scope, action });
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
order.push(group);
|
|
37
|
+
map.set(group, [{ scope, action }]);
|
|
38
|
+
}
|
|
39
|
+
return order.map((group) => ({ group, items: map.get(group) ?? [] }));
|
|
40
|
+
}
|