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.
Files changed (141) hide show
  1. package/package.json +1 -1
  2. package/site/content/docs/elements/flow.mdx +6 -0
  3. package/site/content/docs/elements/gate.mdx +55 -0
  4. package/site/content/docs/elements/store.mdx +13 -15
  5. package/site/content/docs/elements/vault.mdx +3 -0
  6. package/site/content/docs/reference/client.mdx +3 -0
  7. package/site/content/docs/reference/fx.mdx +29 -18
  8. package/site/content/docs/reference/security.md +1 -1
  9. package/src/auth/api-key-sql.test.ts +47 -0
  10. package/src/auth/api-key-sql.ts +254 -0
  11. package/src/auth/api-keys.ts +273 -14
  12. package/src/auth/auth.test.ts +56 -0
  13. package/src/auth/config.ts +5 -5
  14. package/src/auth/index.ts +25 -1
  15. package/src/auth/schema.ts +8 -0
  16. package/src/cli/attach-host-console.test.ts +37 -0
  17. package/src/cli/attach-host-console.ts +6 -0
  18. package/src/compiler/effects-infer.ts +17 -2
  19. package/src/console/server/access.test.ts +116 -8
  20. package/src/console/server/access.ts +70 -23
  21. package/src/console/server/app.ts +1 -0
  22. package/src/console/server/dev-identities.test.ts +127 -0
  23. package/src/console/server/dev-identities.ts +229 -0
  24. package/src/console/server/flows-invoke.test.ts +21 -4
  25. package/src/console/server/flows.ts +113 -58
  26. package/src/console/server/gates.test.ts +24 -0
  27. package/src/console/server/gates.ts +8 -21
  28. package/src/console/server/index.ts +1 -0
  29. package/src/console/server/live.test.ts +26 -1
  30. package/src/console/server/live.ts +4 -2
  31. package/src/console/server/operator-db.ts +2 -0
  32. package/src/console/server/state.ts +29 -55
  33. package/src/console/ui-next/dist/assets/access-page-CAGHrA9H.js +4 -0
  34. package/src/console/ui-next/dist/assets/{agent-disclosure-C0X1fbWF.js → agent-disclosure-BP0Y0Sux.js} +1 -1
  35. package/src/console/ui-next/dist/assets/cache-glyph-B5X-NM0-.js +1 -0
  36. package/src/console/ui-next/dist/assets/call-pii-button-ChAUiLo9.js +1 -0
  37. package/src/console/ui-next/dist/assets/collapsible-DGnOM2ph.js +1 -0
  38. package/src/console/ui-next/dist/assets/copy-inline-button-DBHhgHKP.js +1 -0
  39. package/src/console/ui-next/dist/assets/detail-header-DhHM1iaZ.js +1 -0
  40. package/src/console/ui-next/dist/assets/dropdown-menu-_NjTEo5_.js +1 -0
  41. package/src/console/ui-next/dist/assets/element-icons-BVXtRyd3.js +1 -0
  42. package/src/console/ui-next/dist/assets/explorer-empty-2uIhBu0_.js +1 -0
  43. package/src/console/ui-next/dist/assets/flows-page-B-OUtiAu.js +1 -0
  44. package/src/console/ui-next/dist/assets/{highlighted-json-CS_O8L-r.js → highlighted-json-Awq7gYdu.js} +1 -1
  45. package/src/console/ui-next/dist/assets/http-method-_UHM2ODJ.js +1 -0
  46. package/src/console/ui-next/dist/assets/index-CGoZkILK.js +66 -0
  47. package/src/console/ui-next/dist/assets/index-Ck88Jmv8.css +2 -0
  48. package/src/console/ui-next/dist/assets/link-BX6Vqztd.js +1 -0
  49. package/src/console/ui-next/dist/assets/observability-page-BDyXalNR.js +4 -0
  50. package/src/console/ui-next/dist/assets/replica-lag-DKRrbvdo.js +18 -0
  51. package/src/console/ui-next/dist/assets/request-meta-DV0ywz7t.js +1 -0
  52. package/src/console/ui-next/dist/assets/shortcut-JQIZlWfm.js +1 -0
  53. package/src/console/ui-next/dist/assets/shortcut-keys-DKxNTe_m.js +1 -0
  54. package/src/console/ui-next/dist/assets/skeleton-D-czQJT6.js +1 -0
  55. package/src/console/ui-next/dist/assets/store-page-Xh8Kn3rx.js +41 -0
  56. package/src/console/ui-next/dist/assets/trace-detail-sheet-B09O8rA6.js +2 -0
  57. package/src/console/ui-next/dist/assets/tree-expand-toggle-DkOXA12R.js +54 -0
  58. package/src/console/ui-next/dist/assets/units-page-Dpk40kOQ.js +1 -0
  59. package/src/console/ui-next/dist/assets/{use-vault-list-BLbZhzyF.js → use-vault-list-uk4WVboC.js} +1 -1
  60. package/src/console/ui-next/dist/assets/useMutation-B8EO02Ej.js +1 -0
  61. package/src/console/ui-next/dist/assets/useRender-BE2A9BWC.js +1 -0
  62. package/src/console/ui-next/dist/assets/vault-page-B1dB9Ft0.js +2 -0
  63. package/src/console/ui-next/dist/index.html +11 -9
  64. package/src/console/ui-next/index.html +3 -3
  65. package/src/console/ui-next/src/client.ts +167 -0
  66. package/src/console/ui-next/src/components/shell/app-sidebar.tsx +2 -0
  67. package/src/console/ui-next/src/components/shell/command-items.test.ts +1 -0
  68. package/src/console/ui-next/src/components/shell/command-items.ts +8 -0
  69. package/src/console/ui-next/src/components/shell/shell-layout.tsx +2 -1
  70. package/src/console/ui-next/src/components/ui/sheet-form.tsx +8 -3
  71. package/src/console/ui-next/src/features/access/access-page.tsx +347 -0
  72. package/src/console/ui-next/src/features/access/data/use-access.ts +23 -0
  73. package/src/console/ui-next/src/features/access/detail/access-detail.tsx +552 -0
  74. package/src/console/ui-next/src/features/access/explorer/access-list.tsx +125 -0
  75. package/src/console/ui-next/src/features/access/lib/format-when.test.ts +139 -0
  76. package/src/console/ui-next/src/features/access/lib/format-when.ts +296 -0
  77. package/src/console/ui-next/src/features/access/lib/scope-groups.test.ts +34 -0
  78. package/src/console/ui-next/src/features/access/lib/scope-groups.ts +40 -0
  79. package/src/console/ui-next/src/features/access/sheets/access-allow-fields.tsx +236 -0
  80. package/src/console/ui-next/src/features/access/sheets/access-create-sheet.tsx +316 -0
  81. package/src/console/ui-next/src/features/access/sheets/access-edit-sheet.tsx +240 -0
  82. package/src/console/ui-next/src/features/access/sheets/access-expiry-fields.tsx +66 -0
  83. package/src/console/ui-next/src/features/access/sheets/access-refresh-sheet.tsx +104 -0
  84. package/src/console/ui-next/src/features/access/sheets/access-scope-field.tsx +115 -0
  85. package/src/console/ui-next/src/features/access/sheets/access-secret-sheet.tsx +89 -0
  86. package/src/console/ui-next/src/features/access/sheets/access-sheet-search.tsx +49 -0
  87. package/src/console/ui-next/src/features/access/state/access-selection.ts +84 -0
  88. package/src/console/ui-next/src/features/auth/auth-redirect.test.ts +5 -0
  89. package/src/console/ui-next/src/features/auth/auth-redirect.ts +18 -2
  90. package/src/console/ui-next/src/features/not-found/not-found-page.tsx +1 -1
  91. package/src/console/ui-next/src/lib/document-meta.test.ts +1 -0
  92. package/src/console/ui-next/src/lib/document-meta.ts +2 -1
  93. package/src/console/ui-next/src/lib/last-module-search.test.ts +1 -0
  94. package/src/console/ui-next/src/lib/last-module-search.ts +2 -1
  95. package/src/console/ui-next/src/lib/shortcut.test.ts +2 -0
  96. package/src/console/ui-next/src/lib/shortcut.ts +3 -0
  97. package/src/console/ui-next/src/router.tsx +10 -1
  98. package/src/console/ui-next/ui-next-seed-app-schema.ts +88 -37
  99. package/src/console/ui-next/ui-next-seed-store.ts +43 -15
  100. package/src/console/xss.gate.test.ts +1 -0
  101. package/src/elements/gate/declare.ts +2 -0
  102. package/src/elements/store/cache.ts +1 -1
  103. package/src/elements/store/runtime.ts +14 -0
  104. package/src/elements/store/schema-decl.ts +99 -41
  105. package/src/elements/store/sql-select.types.test.ts +71 -0
  106. package/src/elements/store/sql-session.ts +54 -32
  107. package/src/elements/store.ts +3 -0
  108. package/src/index.ts +1 -0
  109. package/src/kernel/api-key-host-persist.test.ts +143 -0
  110. package/src/kernel/app.ts +14 -2
  111. package/src/kernel/auth-resolve.ts +10 -5
  112. package/src/kernel/boot.test.ts +4 -4
  113. package/src/kernel/fx-auth-keys.test.ts +88 -0
  114. package/src/kernel/fx-auth-keys.ts +198 -0
  115. package/src/kernel/fx.test.ts +17 -1
  116. package/src/kernel/fx.ts +36 -18
  117. package/src/kernel/pipeline-api-key.test.ts +192 -7
  118. package/src/kernel/pipeline.ts +4 -2
  119. package/src/manifest/types.ts +9 -3
  120. package/src/release/build-lib.ts +1 -0
  121. package/src/console/ui-next/dist/assets/cache-glyph-F1FI122b.js +0 -1
  122. package/src/console/ui-next/dist/assets/call-pii-button-DEDCl_j3.js +0 -1
  123. package/src/console/ui-next/dist/assets/collapsible-LPqGvfoz.js +0 -1
  124. package/src/console/ui-next/dist/assets/confirm-sheet-3ptDzXbz.js +0 -1
  125. package/src/console/ui-next/dist/assets/explorer-empty-BvYrygyO.js +0 -1
  126. package/src/console/ui-next/dist/assets/flows-page-Dluo1Bd7.js +0 -1
  127. package/src/console/ui-next/dist/assets/http-method--sWDdXSB.js +0 -1
  128. package/src/console/ui-next/dist/assets/index-Ca3HZMVq.js +0 -66
  129. package/src/console/ui-next/dist/assets/index-UtTDRQpS.css +0 -2
  130. package/src/console/ui-next/dist/assets/link-COeyggt-.js +0 -1
  131. package/src/console/ui-next/dist/assets/observability-page-HK9-BO8z.js +0 -4
  132. package/src/console/ui-next/dist/assets/replica-lag-DBIFFf7d.js +0 -18
  133. package/src/console/ui-next/dist/assets/sheet-form-Dnwa5oOR.js +0 -1
  134. package/src/console/ui-next/dist/assets/shortcut-Cgzuv4k1.js +0 -1
  135. package/src/console/ui-next/dist/assets/shortcut-keys-CoqwEIi0.js +0 -1
  136. package/src/console/ui-next/dist/assets/skeleton-BHcfDYcb.js +0 -1
  137. package/src/console/ui-next/dist/assets/store-page-BSohH3wM.js +0 -41
  138. package/src/console/ui-next/dist/assets/trace-detail-sheet-CFIGRnnA.js +0 -2
  139. package/src/console/ui-next/dist/assets/tree-expand-toggle-BgLl34w7.js +0 -54
  140. package/src/console/ui-next/dist/assets/units-page-B0cFE76A.js +0 -1
  141. 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
+ }