portal-mojo 0.2.0 → 0.2.2
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/docs/README.md +1 -1
- package/docs/admin-credentials.md +77 -4
- package/package.json +1 -1
- package/src/admin/credentials/api-key-rate-limits.tsx +284 -0
- package/src/admin/credentials/group-api-keys.tsx +86 -31
- package/src/admin/credentials/index.ts +1 -0
- package/src/admin/credentials/models.ts +224 -0
- package/src/admin/messaging/models.ts +3 -3
- package/src/admin/messaging/sanitize.ts +4 -3
- package/src/client/mock.ts +34 -1
package/docs/README.md
CHANGED
|
@@ -13,7 +13,7 @@ everything: run the showcase (`npm run dev:showcase`) → **Develop → Componen
|
|
|
13
13
|
| [auth.md](auth.md) | Login flows, token upkeep, hosted-auth (bouncer page) bridge, auth events |
|
|
14
14
|
| [realtime.md](realtime.md) | Auth-challenged WebSocket lifecycle, heartbeat/backoff, refcounted topics, typed event projection, deterministic mock |
|
|
15
15
|
| [auth-pages.md](auth-pages.md) | The in-app auth pages: routes + guard, `VITE_MOJO_AUTH` switch, fresh-auth (440 step-up) modal, MFA panel, password tools |
|
|
16
|
-
| [admin-credentials.md](admin-credentials.md) | Group API keys and webhook subscriptions — dual-mount pages, permission registry, secret-safe create/reveal/rotation |
|
|
16
|
+
| [admin-credentials.md](admin-credentials.md) | Group API keys and webhook subscriptions — dual-mount pages, lossless per-endpoint rate limits, permission registry, secret-safe create/reveal/rotation |
|
|
17
17
|
| [admin-dashboard.md](admin-dashboard.md) | Package-owned global Admin landing, authoritative signals, independent gates, caller-isolated caches, and root fallback |
|
|
18
18
|
| [admin-cloudwatch.md](admin-cloudwatch.md) | Global EC2/RDS/Redis CloudWatch charts, exact resource/fetch wires, constrained controls, refresh ownership, and modal details |
|
|
19
19
|
| [admin-monitoring.md](admin-monitoring.md) | Monitoring Admin registration, logs, and exact Redis-backed metrics-permission mutations |
|
|
@@ -30,8 +30,12 @@ the current operator can see.
|
|
|
30
30
|
|
|
31
31
|
```tsx
|
|
32
32
|
import {
|
|
33
|
+
ApiKeyLimitsSummary,
|
|
34
|
+
ApiKeyRateLimitsEditor,
|
|
33
35
|
GroupApiKeysSection,
|
|
34
36
|
GroupApiKeysPage,
|
|
37
|
+
buildApiKeyLimitPatch,
|
|
38
|
+
readApiKeyRateLimits,
|
|
35
39
|
registerGroupApiKeyPermissions,
|
|
36
40
|
} from 'portal-mojo/admin';
|
|
37
41
|
|
|
@@ -63,10 +67,14 @@ All ordinary API-key lists force `graph=default`, discard URL/persisted
|
|
|
63
67
|
`graph` and unsupported search params before creating a query key, and scrub
|
|
64
68
|
token fields from every list/detail/save result before any cache write.
|
|
65
69
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
+
The guided forms include `send_sms` and the broader `comms` permission. Create
|
|
71
|
+
and edit also expose **Additional permission names** as freeform tags, so a
|
|
72
|
+
new backend permission does not require a portal release before a key can use
|
|
73
|
+
it. Create sends only true guided controls and named tags. Edit diffs only
|
|
74
|
+
controls that were actually rendered, adds newly named tags, and sends `false`
|
|
75
|
+
for removed custom tags. Hidden protected grants are preserved unless an
|
|
76
|
+
operator deliberately enters their name; django-mojo remains authoritative
|
|
77
|
+
for every grant and revocation.
|
|
70
78
|
|
|
71
79
|
The permission catalog is live and injectable:
|
|
72
80
|
|
|
@@ -85,6 +93,71 @@ Registration replaces the same permission name and appends new names. Mounted
|
|
|
85
93
|
editors subscribe to changes. `grantPermissions` gates the control itself; the
|
|
86
94
|
backend remains authoritative on every save.
|
|
87
95
|
|
|
96
|
+
`normalizeApiKeyPermissionNames()` trims and deduplicates TagInput CSV (or an
|
|
97
|
+
array) and rejects the reserved `__replace` JSON update signal.
|
|
98
|
+
`buildApiKeyPermissionChanges()` constructs the narrow merge patch used by the
|
|
99
|
+
edit form without resending unchanged custom grants.
|
|
100
|
+
|
|
101
|
+
### Rate-limit overrides
|
|
102
|
+
|
|
103
|
+
Group API keys expose the raw django-mojo JSONField shape without normalizing
|
|
104
|
+
endpoint identity:
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
const limits = {
|
|
108
|
+
orders: { limit: 500, window: 5, extension_owned: { burst: 20 } },
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const state = readApiKeyRateLimits(limits);
|
|
112
|
+
const patch = buildApiKeyLimitPatch(
|
|
113
|
+
'orders',
|
|
114
|
+
{ limit: 750, window: 10 },
|
|
115
|
+
limits.orders, // preserves extension_owned and every other nested sibling
|
|
116
|
+
);
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
`limit` and `window` are positive integers with no fixed maximum. `window` is
|
|
120
|
+
always measured in **minutes** in `ApiKey.limits`; django-mojo converts it to
|
|
121
|
+
seconds at enforcement time. `ApiKeyLimitsSummary` is the bounded card/table
|
|
122
|
+
presentation. `ApiKeyRateLimitsEditor` is the controlled detail editor and
|
|
123
|
+
keeps saves on the rejecting `GroupApiKeyModel.useSave()` path.
|
|
124
|
+
|
|
125
|
+
This UI depends on django-mojo 1.20.0 / item 3105. A genuinely empty object is
|
|
126
|
+
shown as **Unlimited (default)** because ordinary API-key throughput has no
|
|
127
|
+
built-in hard ceiling. Strict endpoint limits, a positive decorator fallback,
|
|
128
|
+
or an enabled deployment ceiling may still apply. When safe overrides exist,
|
|
129
|
+
unlisted ordinary endpoint buckets remain unlimited. Any malformed top-level
|
|
130
|
+
or nested value shows **Review required** instead of either safe claim.
|
|
131
|
+
|
|
132
|
+
Add trims the new endpoint name and rejects blank, duplicate, and reserved
|
|
133
|
+
`__replace` names. Edit keeps the stored endpoint identity immutable. Clear
|
|
134
|
+
sends one narrow null tombstone, for example `{limits: {orders: null}}`; the
|
|
135
|
+
authoritative complete row returned by django-mojo replaces Query state while
|
|
136
|
+
every unrelated endpoint remains intact.
|
|
137
|
+
|
|
138
|
+
Legacy data is lossless:
|
|
139
|
+
|
|
140
|
+
- empty and whitespace-only keys are quoted distinctly and clear only by their
|
|
141
|
+
exact raw key;
|
|
142
|
+
- scalar entries may be cleared but not edited as structured limits;
|
|
143
|
+
- partial object entries may be repaired, preserving extra nested siblings. A
|
|
144
|
+
positive limit with no `window` currently uses the endpoint decorator's
|
|
145
|
+
default window; other malformed/non-positive entries fail open;
|
|
146
|
+
- valid entries with extra siblings retain them on edit;
|
|
147
|
+
- a stored `__replace` root key is read-only. django-mojo consumes that name as
|
|
148
|
+
a JSON replacement signal, so generic edit/clear controls would be dishonest;
|
|
149
|
+
repair the full stored value outside this editor.
|
|
150
|
+
|
|
151
|
+
Mutation controls are fail-closed behind the supplied `PermSpec`. The global
|
|
152
|
+
detail uses `GLOBAL_CREDENTIAL_PERMS`; embedded group surfaces default to
|
|
153
|
+
`GROUP_CREDENTIAL_PERMS`. Denied viewers receive no mutation controls or extra
|
|
154
|
+
credential queries. Limit mutations never request the token graph and retain
|
|
155
|
+
the existing pre-cache token scrubber.
|
|
156
|
+
|
|
157
|
+
Pitfalls: do not clone the raw object into a typed-only map, trim an existing
|
|
158
|
+
key, send `__replace`, label malformed data unlimited, measure windows in
|
|
159
|
+
seconds, or replace the full JSONField to clear one endpoint.
|
|
160
|
+
|
|
88
161
|
## Webhooks
|
|
89
162
|
|
|
90
163
|
```tsx
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "portal-mojo",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "React portal toolkit for django-mojo — typed client + params store, mission-control UI, dependency-free SVG charts, admin section bundles.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { useCan, type PermSpec } from '../../client/runtime';
|
|
2
|
+
import { Badge, SchemaForm, modal, toast, type Field, type FormData } from '../../ui';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import {
|
|
5
|
+
GROUP_CREDENTIAL_PERMS, GroupApiKeyModel,
|
|
6
|
+
buildApiKeyLimitPatch, readApiKeyRateLimits, validateApiKeyRateLimitInput,
|
|
7
|
+
type ApiKeyRateLimitEntry, type GroupApiKeyRow,
|
|
8
|
+
} from './models';
|
|
9
|
+
|
|
10
|
+
function endpointLabel(rawEndpoint: string): string {
|
|
11
|
+
return JSON.stringify(rawEndpoint);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function rawValueLabel(value: unknown): string {
|
|
15
|
+
if (value === undefined) return 'undefined';
|
|
16
|
+
try {
|
|
17
|
+
const serialized = JSON.stringify(value);
|
|
18
|
+
return serialized === undefined ? String(value) : serialized;
|
|
19
|
+
} catch {
|
|
20
|
+
return String(value);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function entryTone(entry: ApiKeyRateLimitEntry): 'success' | 'warning' | 'danger' {
|
|
25
|
+
if (entry.kind === 'valid') return 'success';
|
|
26
|
+
if (entry.kind === 'reserved' || entry.kind === 'scalar') return 'danger';
|
|
27
|
+
return 'warning';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Compact, bounded rate-limit state for cards and table cells. */
|
|
31
|
+
export function ApiKeyLimitsSummary({ limits, maxEntries = 2 }: {
|
|
32
|
+
limits: unknown;
|
|
33
|
+
maxEntries?: number;
|
|
34
|
+
}) {
|
|
35
|
+
const read = readApiKeyRateLimits(limits);
|
|
36
|
+
if (read.isEmpty) {
|
|
37
|
+
return (
|
|
38
|
+
<span className="ga-limit-summary ga-limit-summary-empty">
|
|
39
|
+
<i className="bi bi-infinity" /> Unlimited (default)
|
|
40
|
+
</span>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
if (!read.isObject || read.hasUnsafe) {
|
|
44
|
+
const visible = read.entries.slice(0, Math.max(0, maxEntries));
|
|
45
|
+
return (
|
|
46
|
+
<span className="ga-limit-summary ga-limit-summary-review" title="Stored rate limits require review">
|
|
47
|
+
<span><i className="bi bi-exclamation-triangle" /> Review required</span>
|
|
48
|
+
{visible.map((entry) => (
|
|
49
|
+
<code key={entry.rawEndpoint}>{endpointLabel(entry.rawEndpoint)}</code>
|
|
50
|
+
))}
|
|
51
|
+
{read.entries.length > visible.length && <span>+{read.entries.length - visible.length}</span>}
|
|
52
|
+
</span>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
const visible = read.entries.slice(0, Math.max(0, maxEntries));
|
|
56
|
+
return (
|
|
57
|
+
<span className="ga-limit-summary ga-limit-summary-configured">
|
|
58
|
+
{visible.map((entry) => (
|
|
59
|
+
<code key={entry.rawEndpoint}>
|
|
60
|
+
{entry.rawEndpoint} {entry.override!.limit}/{entry.override!.window}m
|
|
61
|
+
</code>
|
|
62
|
+
))}
|
|
63
|
+
{read.entries.length > visible.length && <span>+{read.entries.length - visible.length}</span>}
|
|
64
|
+
<span className="ga-limit-summary-note">all other endpoints unlimited</span>
|
|
65
|
+
</span>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const positiveIntegerSchema = (label: string) => z.any().refine((value) => {
|
|
70
|
+
if (typeof value === 'boolean') return false;
|
|
71
|
+
const text = String(value ?? '').trim();
|
|
72
|
+
if (!text) return false;
|
|
73
|
+
const parsed = Number(text);
|
|
74
|
+
return Number.isInteger(parsed) && parsed > 0;
|
|
75
|
+
}, { message: `${label} must be a positive integer.` });
|
|
76
|
+
|
|
77
|
+
const LIMIT_FIELDS: Field[] = [
|
|
78
|
+
{
|
|
79
|
+
name: 'limit', type: 'text', label: 'Request limit', required: true,
|
|
80
|
+
min: 1, step: 1, columns: 6,
|
|
81
|
+
schema: positiveIntegerSchema('Request limit'),
|
|
82
|
+
help: 'Positive whole number; there is no fixed maximum.',
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
name: 'window', type: 'text', label: 'Window (minutes)', required: true,
|
|
86
|
+
min: 1, step: 1, columns: 6,
|
|
87
|
+
schema: positiveIntegerSchema('Window in minutes'),
|
|
88
|
+
help: 'Positive whole number of minutes.',
|
|
89
|
+
},
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
const ADD_FIELDS: Field[] = [
|
|
93
|
+
{
|
|
94
|
+
name: 'endpoint', type: 'text', label: 'Endpoint key', required: true,
|
|
95
|
+
placeholder: 'orders',
|
|
96
|
+
help: 'The endpoint decorator bucket key. New names are trimmed and cannot be renamed.',
|
|
97
|
+
},
|
|
98
|
+
...LIMIT_FIELDS,
|
|
99
|
+
];
|
|
100
|
+
|
|
101
|
+
function initialEntry(entry: ApiKeyRateLimitEntry): FormData {
|
|
102
|
+
const raw = entry.rawValue && typeof entry.rawValue === 'object' && !Array.isArray(entry.rawValue)
|
|
103
|
+
? entry.rawValue as Record<string, unknown>
|
|
104
|
+
: {};
|
|
105
|
+
return {
|
|
106
|
+
limit: typeof raw.limit === 'number' && Number.isInteger(raw.limit) && raw.limit > 0 ? raw.limit : '',
|
|
107
|
+
window: typeof raw.window === 'number' && Number.isInteger(raw.window) && raw.window > 0 ? raw.window : '',
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Lossless detail editor. All mutation controls stay behind the supplied gate. */
|
|
112
|
+
export function ApiKeyRateLimitsEditor({ row, permission = GROUP_CREDENTIAL_PERMS }: {
|
|
113
|
+
row: GroupApiKeyRow;
|
|
114
|
+
permission?: PermSpec;
|
|
115
|
+
}) {
|
|
116
|
+
const { can } = useCan(permission);
|
|
117
|
+
const save = GroupApiKeyModel.useSave();
|
|
118
|
+
const read = readApiKeyRateLimits(row.limits);
|
|
119
|
+
|
|
120
|
+
const savePatch = async (patch: Record<string, unknown>, success: string) => {
|
|
121
|
+
try {
|
|
122
|
+
const saved = await save.mutateAsync({ id: row.id, changes: { limits: patch } });
|
|
123
|
+
toast.success(success);
|
|
124
|
+
return saved;
|
|
125
|
+
} catch (error) {
|
|
126
|
+
const message = error instanceof Error ? error.message : 'Failed to save the rate limit override';
|
|
127
|
+
toast.error(message);
|
|
128
|
+
throw error instanceof Error ? error : new Error(message);
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const openAdd = () => {
|
|
133
|
+
if (!can || !read.isObject || save.isPending) return;
|
|
134
|
+
let pending = false;
|
|
135
|
+
void modal.open<GroupApiKeyRow | null>((close) => (
|
|
136
|
+
<div className="modal-pad">
|
|
137
|
+
<h2 className="modal-title">Add rate limit override</h2>
|
|
138
|
+
<p className="dim">This creates a hard per-key ceiling for one endpoint bucket.</p>
|
|
139
|
+
<SchemaForm
|
|
140
|
+
fields={ADD_FIELDS}
|
|
141
|
+
submitText="Add override"
|
|
142
|
+
onBusyChange={(busy) => { pending = busy; }}
|
|
143
|
+
onCancel={() => close(null)}
|
|
144
|
+
onSubmit={async (form) => {
|
|
145
|
+
const parsed = validateApiKeyRateLimitInput(form, { existing: row.limits });
|
|
146
|
+
const saved = await savePatch(
|
|
147
|
+
buildApiKeyLimitPatch(parsed.endpoint, parsed.override),
|
|
148
|
+
'Rate limit override added',
|
|
149
|
+
);
|
|
150
|
+
close(saved);
|
|
151
|
+
return saved;
|
|
152
|
+
}}
|
|
153
|
+
/>
|
|
154
|
+
</div>
|
|
155
|
+
), { size: 'sm', canDismiss: () => !pending });
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const openEdit = (entry: ApiKeyRateLimitEntry) => {
|
|
159
|
+
if (!can || !entry.canEdit || save.isPending) return;
|
|
160
|
+
let pending = false;
|
|
161
|
+
void modal.open<GroupApiKeyRow | null>((close) => (
|
|
162
|
+
<div className="modal-pad">
|
|
163
|
+
<h2 className="modal-title">{entry.kind === 'valid' ? 'Edit' : 'Repair'} rate limit override</h2>
|
|
164
|
+
<p className="dim">
|
|
165
|
+
Endpoint identity is immutable: <code>{endpointLabel(entry.rawEndpoint)}</code>
|
|
166
|
+
</p>
|
|
167
|
+
<SchemaForm
|
|
168
|
+
fields={LIMIT_FIELDS}
|
|
169
|
+
initial={initialEntry(entry)}
|
|
170
|
+
submitText={entry.kind === 'valid' ? 'Save override' : 'Repair override'}
|
|
171
|
+
onBusyChange={(busy) => { pending = busy; }}
|
|
172
|
+
onCancel={() => close(null)}
|
|
173
|
+
onSubmit={async (form) => {
|
|
174
|
+
const parsed = validateApiKeyRateLimitInput(form, { fixedEndpoint: entry.rawEndpoint });
|
|
175
|
+
const saved = await savePatch(
|
|
176
|
+
buildApiKeyLimitPatch(entry.rawEndpoint, parsed.override, entry.rawValue),
|
|
177
|
+
entry.kind === 'valid' ? 'Rate limit override updated' : 'Rate limit override repaired',
|
|
178
|
+
);
|
|
179
|
+
close(saved);
|
|
180
|
+
return saved;
|
|
181
|
+
}}
|
|
182
|
+
/>
|
|
183
|
+
</div>
|
|
184
|
+
), { size: 'sm', canDismiss: () => !pending });
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const clearEntry = async (entry: ApiKeyRateLimitEntry) => {
|
|
188
|
+
if (!can || !entry.canClear || save.isPending) return;
|
|
189
|
+
const confirmed = await modal.confirm({
|
|
190
|
+
title: 'Clear rate limit override?',
|
|
191
|
+
message: (
|
|
192
|
+
<>
|
|
193
|
+
Clear the exact stored endpoint <code>{endpointLabel(entry.rawEndpoint)}</code>?
|
|
194
|
+
Other endpoints and extension-owned values are preserved. Clearing may increase throughput.
|
|
195
|
+
</>
|
|
196
|
+
),
|
|
197
|
+
confirmText: 'Clear override',
|
|
198
|
+
danger: true,
|
|
199
|
+
});
|
|
200
|
+
if (!confirmed) return;
|
|
201
|
+
try {
|
|
202
|
+
await savePatch(buildApiKeyLimitPatch(entry.rawEndpoint, null), 'Rate limit override cleared');
|
|
203
|
+
} catch {
|
|
204
|
+
// savePatch already surfaced the rejecting save through the shared toast.
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
if (!read.isObject) {
|
|
209
|
+
return (
|
|
210
|
+
<div className="ga-limit-review" role="alert">
|
|
211
|
+
<i className="bi bi-exclamation-triangle" />
|
|
212
|
+
<div>
|
|
213
|
+
<b>Stored rate limits are malformed.</b>
|
|
214
|
+
<p>They do not create a per-key hard ceiling and cannot be edited safely here. Repair the full value through the backend.</p>
|
|
215
|
+
</div>
|
|
216
|
+
</div>
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return (
|
|
221
|
+
<div className="ga-limit-editor">
|
|
222
|
+
<div className="ga-limit-head">
|
|
223
|
+
<div>
|
|
224
|
+
<h3>Per-endpoint hard overrides</h3>
|
|
225
|
+
<p className="dim">Windows are stored in minutes. The backend remains authoritative on every save.</p>
|
|
226
|
+
</div>
|
|
227
|
+
{can && (
|
|
228
|
+
<button className="btn btn-primary btn-compact" disabled={save.isPending} onClick={openAdd}>
|
|
229
|
+
<i className="bi bi-plus-lg" /> Add override
|
|
230
|
+
</button>
|
|
231
|
+
)}
|
|
232
|
+
</div>
|
|
233
|
+
|
|
234
|
+
{read.isEmpty ? (
|
|
235
|
+
<div className="ga-limit-empty">
|
|
236
|
+
<b><i className="bi bi-infinity" /> Unlimited (default)</b>
|
|
237
|
+
<p>No per-key hard overrides are configured. Strict endpoint limits, positive decorator fallbacks, and enabled deployment ceilings can still apply.</p>
|
|
238
|
+
</div>
|
|
239
|
+
) : (
|
|
240
|
+
<>
|
|
241
|
+
{read.hasUnsafe ? (
|
|
242
|
+
<div className="ga-limit-review" role="alert">
|
|
243
|
+
<i className="bi bi-exclamation-triangle" />
|
|
244
|
+
<div><b>Review stored rate limits.</b><p>Unsafe entries are preserved and are not treated as an unlimited or valid configured state.</p></div>
|
|
245
|
+
</div>
|
|
246
|
+
) : (
|
|
247
|
+
<p className="ga-limit-policy-note">
|
|
248
|
+
These endpoints have explicit hard ceilings; every unlisted ordinary endpoint remains unlimited by default.
|
|
249
|
+
</p>
|
|
250
|
+
)}
|
|
251
|
+
<div className="ga-limit-table-wrap">
|
|
252
|
+
<table className="ga-limit-table">
|
|
253
|
+
<thead><tr><th>Endpoint key</th><th>Limit</th><th>Window</th><th>State</th><th>Actions</th></tr></thead>
|
|
254
|
+
<tbody>
|
|
255
|
+
{read.entries.map((entry) => (
|
|
256
|
+
<tr key={entry.rawEndpoint}>
|
|
257
|
+
<td><code>{endpointLabel(entry.rawEndpoint)}</code></td>
|
|
258
|
+
<td>{entry.override?.limit ?? '—'}</td>
|
|
259
|
+
<td>{entry.override ? `${entry.override.window} min` : '—'}</td>
|
|
260
|
+
<td>
|
|
261
|
+
<Badge tone={entryTone(entry)}>
|
|
262
|
+
{entry.kind === 'valid' ? 'Configured' : entry.kind === 'repairable' ? 'Repairable' : entry.kind === 'reserved' ? 'Backend repair' : 'Invalid scalar'}
|
|
263
|
+
</Badge>
|
|
264
|
+
{entry.issue && <small className="ga-limit-issue">{entry.issue}</small>}
|
|
265
|
+
{entry.kind !== 'valid' && <small className="ga-limit-raw">Raw: {rawValueLabel(entry.rawValue)}</small>}
|
|
266
|
+
</td>
|
|
267
|
+
<td>
|
|
268
|
+
{can && (entry.canEdit || entry.canClear) ? (
|
|
269
|
+
<span className="ga-limit-actions">
|
|
270
|
+
{entry.canEdit && <button className="btn btn-compact" disabled={save.isPending} onClick={() => openEdit(entry)}>{entry.kind === 'valid' ? 'Edit' : 'Repair'}</button>}
|
|
271
|
+
{entry.canClear && <button className="btn btn-compact btn-danger-ghost" disabled={save.isPending} onClick={() => void clearEntry(entry)}>Clear</button>}
|
|
272
|
+
</span>
|
|
273
|
+
) : <span className="dim">Read only</span>}
|
|
274
|
+
</td>
|
|
275
|
+
</tr>
|
|
276
|
+
))}
|
|
277
|
+
</tbody>
|
|
278
|
+
</table>
|
|
279
|
+
</div>
|
|
280
|
+
</>
|
|
281
|
+
)}
|
|
282
|
+
</div>
|
|
283
|
+
);
|
|
284
|
+
}
|
|
@@ -11,12 +11,14 @@ import {
|
|
|
11
11
|
} from '../../ui';
|
|
12
12
|
import {
|
|
13
13
|
GLOBAL_CREDENTIAL_PERMS, GROUP_CREDENTIAL_PERMS,
|
|
14
|
-
GroupApiKeyModel,
|
|
14
|
+
GroupApiKeyModel, buildApiKeyPermissionChanges, customApiKeyPermissionNames,
|
|
15
|
+
fetchApiKeyToken,
|
|
15
16
|
getGroupApiKeyPermissions, grantedPermissions,
|
|
16
17
|
groupApiKeyPermissionsVersion, subscribeGroupApiKeyPermissions,
|
|
17
|
-
useCreateGroupApiKey,
|
|
18
|
+
normalizeApiKeyPermissionNames, readApiKeyRateLimits, useCreateGroupApiKey,
|
|
18
19
|
type ApiKeyPermissionDef, type CredentialGroup, type GroupApiKeyRow,
|
|
19
20
|
} from './models';
|
|
21
|
+
import { ApiKeyLimitsSummary, ApiKeyRateLimitsEditor } from './api-key-rate-limits';
|
|
20
22
|
import { showSecretDialog } from './secret-dialog';
|
|
21
23
|
|
|
22
24
|
function groupLabel(group: GroupApiKeyRow['group']): string {
|
|
@@ -59,6 +61,14 @@ function permissionSeed(
|
|
|
59
61
|
]));
|
|
60
62
|
}
|
|
61
63
|
|
|
64
|
+
const CUSTOM_PERMISSION_FIELD: Field = {
|
|
65
|
+
name: 'custom_permissions',
|
|
66
|
+
type: 'tags',
|
|
67
|
+
label: 'Additional permission names',
|
|
68
|
+
placeholder: 'Type a permission and press Enter',
|
|
69
|
+
help: 'Add permission strings that are not listed above. Remove a tag to revoke it when editing; the server authorizes every change.',
|
|
70
|
+
};
|
|
71
|
+
|
|
62
72
|
function TokenFooter({ permissions }: { permissions: string[] }) {
|
|
63
73
|
return (
|
|
64
74
|
<div className="ga-secret-footnote">
|
|
@@ -105,6 +115,7 @@ function useGroupApiKeyActions(permission: PermSpec) {
|
|
|
105
115
|
placeholder: 'Search groups…',
|
|
106
116
|
} satisfies Field] : []),
|
|
107
117
|
...permissionFields(renderedPermissions),
|
|
118
|
+
CUSTOM_PERMISSION_FIELD,
|
|
108
119
|
]}
|
|
109
120
|
submitText="Create key"
|
|
110
121
|
onCancel={() => close(null)}
|
|
@@ -115,8 +126,16 @@ function useGroupApiKeyActions(permission: PermSpec) {
|
|
|
115
126
|
if (!result) return;
|
|
116
127
|
|
|
117
128
|
const permissions: Record<string, true> = {};
|
|
118
|
-
|
|
119
|
-
|
|
129
|
+
try {
|
|
130
|
+
for (const def of renderedPermissions) {
|
|
131
|
+
if (result[`permissions.${def.name}`] === true) permissions[def.name] = true;
|
|
132
|
+
}
|
|
133
|
+
for (const name of normalizeApiKeyPermissionNames(result.custom_permissions)) {
|
|
134
|
+
permissions[name] = true;
|
|
135
|
+
}
|
|
136
|
+
} catch (error) {
|
|
137
|
+
toast.error(error instanceof Error ? error.message : 'Invalid permission name');
|
|
138
|
+
return;
|
|
120
139
|
}
|
|
121
140
|
const group = fixedGroup?.id ?? Number(result.group);
|
|
122
141
|
if (!Number.isFinite(group) || group <= 0) {
|
|
@@ -161,8 +180,14 @@ function useGroupApiKeyActions(permission: PermSpec) {
|
|
|
161
180
|
{ name: 'name', type: 'text', label: 'Name', required: true, columns: 6 },
|
|
162
181
|
{ name: 'is_active', type: 'switch', label: 'Active', columns: 6 },
|
|
163
182
|
...renderedFields,
|
|
183
|
+
CUSTOM_PERMISSION_FIELD,
|
|
164
184
|
]}
|
|
165
|
-
initial={{
|
|
185
|
+
initial={{
|
|
186
|
+
name: row.name,
|
|
187
|
+
is_active: row.is_active,
|
|
188
|
+
...before,
|
|
189
|
+
custom_permissions: customApiKeyPermissionNames(row.permissions).join(','),
|
|
190
|
+
}}
|
|
166
191
|
submitText="Save changes"
|
|
167
192
|
onCancel={() => close(null)}
|
|
168
193
|
onSubmit={(form) => close(form)}
|
|
@@ -177,14 +202,20 @@ function useGroupApiKeyActions(permission: PermSpec) {
|
|
|
177
202
|
const nextActive = result.is_active === true;
|
|
178
203
|
if (nextActive !== row.is_active) changes.is_active = nextActive;
|
|
179
204
|
|
|
180
|
-
// Diff only controls that were actually rendered
|
|
181
|
-
//
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
205
|
+
// Diff only guided controls that were actually rendered, plus the
|
|
206
|
+
// explicit freeform set. Protected hidden grants remain absent unless
|
|
207
|
+
// the operator deliberately types one (the server is authoritative).
|
|
208
|
+
let permissionChanges: Record<string, boolean>;
|
|
209
|
+
try {
|
|
210
|
+
permissionChanges = buildApiKeyPermissionChanges(
|
|
211
|
+
row.permissions,
|
|
212
|
+
renderedPermissions,
|
|
213
|
+
result,
|
|
214
|
+
result.custom_permissions,
|
|
215
|
+
);
|
|
216
|
+
} catch (error) {
|
|
217
|
+
toast.error(error instanceof Error ? error.message : 'Invalid permission name');
|
|
218
|
+
return;
|
|
188
219
|
}
|
|
189
220
|
if (Object.keys(permissionChanges).length) changes.permissions = permissionChanges;
|
|
190
221
|
if (!Object.keys(changes).length) return;
|
|
@@ -257,6 +288,7 @@ function ApiKeyCard({ row, actions }: {
|
|
|
257
288
|
? permissions.map((permission) => <Badge key={permission} tone="info">{permission}</Badge>)
|
|
258
289
|
: <span className="dim-italic">No permissions granted</span>}
|
|
259
290
|
</div>
|
|
291
|
+
<ApiKeyLimitsSummary limits={row.limits} />
|
|
260
292
|
<div className="dim ga-card-meta">
|
|
261
293
|
Last used <b>{fmt.relative(row.last_used, 'never')}</b> · Created {fmt.date(row.created)}
|
|
262
294
|
{row.expires_at != null && <> · Expires {fmt.date(row.expires_at)}</>}
|
|
@@ -315,12 +347,18 @@ export function GroupApiKeysSection({ group, permission = GROUP_CREDENTIAL_PERMS
|
|
|
315
347
|
return <GroupApiKeysContent group={group} permission={permission} />;
|
|
316
348
|
}
|
|
317
349
|
|
|
318
|
-
|
|
350
|
+
function AuthorizedGroupApiKeyDetail({ id, onClose }: { id: number; onClose: () => void }) {
|
|
319
351
|
const { data: row, isPending, error } = GroupApiKeyModel.useOne(id);
|
|
320
352
|
const actions = useGroupApiKeyActions(GLOBAL_CREDENTIAL_PERMS);
|
|
321
353
|
if (isPending) return <div className="modal-pad dim">Loading API key…</div>;
|
|
322
354
|
if (!row || error) return <div className="modal-pad text-bad">{error?.message ?? 'API key not found'}</div>;
|
|
323
355
|
const permissions = grantedPermissions(row.permissions);
|
|
356
|
+
const limits = readApiKeyRateLimits(row.limits);
|
|
357
|
+
const limitsChip = limits.isEmpty
|
|
358
|
+
? { text: 'Unlimited (default)', tone: 'muted' as const }
|
|
359
|
+
: limits.hasUnsafe || !limits.isObject
|
|
360
|
+
? { text: 'Limits review required', tone: 'warning' as const }
|
|
361
|
+
: { text: `${limits.entries.length} rate limit${limits.entries.length === 1 ? '' : 's'}`, tone: 'info' as const };
|
|
324
362
|
return (
|
|
325
363
|
<DetailView
|
|
326
364
|
icon="bi-key"
|
|
@@ -329,6 +367,7 @@ export function GroupApiKeyDetail({ id, onClose }: { id: number; onClose: () =>
|
|
|
329
367
|
chips={[
|
|
330
368
|
{ text: row.is_active ? 'Active' : 'Inactive', tone: row.is_active ? 'success' : 'muted' },
|
|
331
369
|
{ text: `${permissions.length} grants`, tone: 'info' },
|
|
370
|
+
limitsChip,
|
|
332
371
|
]}
|
|
333
372
|
sections={[
|
|
334
373
|
{
|
|
@@ -339,22 +378,24 @@ export function GroupApiKeyDetail({ id, onClose }: { id: number; onClose: () =>
|
|
|
339
378
|
<FlatRow label="Created">{fmt.datetime(row.created)}</FlatRow>
|
|
340
379
|
<FlatRow label="Last used">{fmt.relative(row.last_used, 'never')}</FlatRow>
|
|
341
380
|
<FlatRow label="Expires">{row.expires_at ? fmt.datetime(row.expires_at) : 'Never'}</FlatRow>
|
|
342
|
-
|
|
343
|
-
<
|
|
344
|
-
<
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
<
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
381
|
+
{actions.canManage && (
|
|
382
|
+
<div className="ga-toolbar" style={{ marginTop: 16 }}>
|
|
383
|
+
<button className="btn btn-compact" onClick={() => void actions.editKey(row)}>
|
|
384
|
+
<i className="bi bi-pencil" /> Edit
|
|
385
|
+
</button>
|
|
386
|
+
<button className="btn btn-compact" onClick={() => void actions.revealToken(row)}>
|
|
387
|
+
<i className="bi bi-eye" /> Reveal audited token
|
|
388
|
+
</button>
|
|
389
|
+
<ArmedButton
|
|
390
|
+
className="btn-compact"
|
|
391
|
+
label="Delete"
|
|
392
|
+
armedLabel="Click again — delete now"
|
|
393
|
+
onConfirm={async () => {
|
|
394
|
+
if (await actions.deleteKey(row)) onClose();
|
|
395
|
+
}}
|
|
396
|
+
/>
|
|
397
|
+
</div>
|
|
398
|
+
)}
|
|
358
399
|
</>
|
|
359
400
|
),
|
|
360
401
|
},
|
|
@@ -367,10 +408,17 @@ export function GroupApiKeyDetail({ id, onClose }: { id: number; onClose: () =>
|
|
|
367
408
|
? permissions.map((permission) => <Badge key={permission} tone="info">{permission}</Badge>)
|
|
368
409
|
: <span className="dim-italic">No permissions granted</span>}
|
|
369
410
|
</div>
|
|
370
|
-
<p className="dim">
|
|
411
|
+
<p className="dim">
|
|
412
|
+
Use Edit to add or remove arbitrary permission names. Hidden protected controls are never changed implicitly.
|
|
413
|
+
</p>
|
|
371
414
|
</>
|
|
372
415
|
),
|
|
373
416
|
},
|
|
417
|
+
{
|
|
418
|
+
key: 'limits', label: 'Rate Limits', icon: 'bi-speedometer2', render: () => (
|
|
419
|
+
<ApiKeyRateLimitsEditor row={row} permission={GLOBAL_CREDENTIAL_PERMS} />
|
|
420
|
+
),
|
|
421
|
+
},
|
|
374
422
|
]}
|
|
375
423
|
initialSection="permissions"
|
|
376
424
|
onClose={onClose}
|
|
@@ -378,6 +426,12 @@ export function GroupApiKeyDetail({ id, onClose }: { id: number; onClose: () =>
|
|
|
378
426
|
);
|
|
379
427
|
}
|
|
380
428
|
|
|
429
|
+
export function GroupApiKeyDetail({ id, onClose }: { id: number; onClose: () => void }) {
|
|
430
|
+
const { can } = useCan(GLOBAL_CREDENTIAL_PERMS);
|
|
431
|
+
if (!can) return <div className="modal-pad dim">API key details are unavailable for this account.</div>;
|
|
432
|
+
return <AuthorizedGroupApiKeyDetail id={id} onClose={onClose} />;
|
|
433
|
+
}
|
|
434
|
+
|
|
381
435
|
const API_KEY_COLUMNS: Column<GroupApiKeyRow>[] = [
|
|
382
436
|
{
|
|
383
437
|
key: 'name', label: 'Key', sortable: true, hideable: false, render: (row) => (
|
|
@@ -390,6 +444,7 @@ const API_KEY_COLUMNS: Column<GroupApiKeyRow>[] = [
|
|
|
390
444
|
{ key: 'group', label: 'Group', render: (row) => groupLabel(row.group) },
|
|
391
445
|
{ key: 'is_active', label: 'Status', render: (row) => <Badge tone={row.is_active ? 'success' : 'muted'}>{row.is_active ? 'Active' : 'Inactive'}</Badge> },
|
|
392
446
|
{ key: 'permissions', label: 'Grants', align: 'center', render: (row) => String(grantedPermissions(row.permissions).length) },
|
|
447
|
+
{ key: 'limits', label: 'Limits', hideable: false, render: (row) => <ApiKeyLimitsSummary limits={row.limits} /> },
|
|
393
448
|
{ key: 'last_used', label: 'Last used', sortable: true, render: (row) => fmt.relative(row.last_used, 'never') },
|
|
394
449
|
{ key: 'created', label: 'Created', sortable: true, render: (row) => fmt.date(row.created) },
|
|
395
450
|
];
|
|
@@ -35,6 +35,166 @@ export interface GroupApiKeyRow {
|
|
|
35
35
|
user: unknown | null;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
export interface ApiKeyRateLimitOverride {
|
|
39
|
+
/** Requests permitted inside `window`. Positive whole number, no fixed maximum. */
|
|
40
|
+
limit: number;
|
|
41
|
+
/** Window length in minutes. Positive whole number, no fixed maximum. */
|
|
42
|
+
window: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type ApiKeyRateLimitEntryKind = 'valid' | 'repairable' | 'scalar' | 'reserved';
|
|
46
|
+
|
|
47
|
+
/** A derived view of one raw `ApiKey.limits` entry. The source value is never rewritten. */
|
|
48
|
+
export interface ApiKeyRateLimitEntry {
|
|
49
|
+
rawEndpoint: string;
|
|
50
|
+
rawValue: unknown;
|
|
51
|
+
kind: ApiKeyRateLimitEntryKind;
|
|
52
|
+
override: ApiKeyRateLimitOverride | null;
|
|
53
|
+
issue: string | null;
|
|
54
|
+
canEdit: boolean;
|
|
55
|
+
canClear: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface ApiKeyRateLimitsRead {
|
|
59
|
+
/** False when the top-level wire value is not a JSON object. */
|
|
60
|
+
isObject: boolean;
|
|
61
|
+
/** True only for a valid, genuinely empty object. */
|
|
62
|
+
isEmpty: boolean;
|
|
63
|
+
/** Unsafe top-level or entry data prevents an unlimited/configured-safe claim. */
|
|
64
|
+
hasUnsafe: boolean;
|
|
65
|
+
entries: ApiKeyRateLimitEntry[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
69
|
+
return value != null && typeof value === 'object' && !Array.isArray(value);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function isPositiveInteger(value: unknown): value is number {
|
|
73
|
+
return typeof value === 'number' && Number.isInteger(value) && value > 0;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Derive editable and review-required entries without normalizing raw endpoint
|
|
78
|
+
* identity or discarding extension-owned values.
|
|
79
|
+
*/
|
|
80
|
+
export function readApiKeyRateLimits(raw: unknown): ApiKeyRateLimitsRead {
|
|
81
|
+
if (!isPlainObject(raw)) {
|
|
82
|
+
return { isObject: false, isEmpty: false, hasUnsafe: true, entries: [] };
|
|
83
|
+
}
|
|
84
|
+
const entries = Object.entries(raw)
|
|
85
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
86
|
+
.map<ApiKeyRateLimitEntry>(([rawEndpoint, rawValue]) => {
|
|
87
|
+
const reserved = rawEndpoint === '__replace';
|
|
88
|
+
const blank = rawEndpoint.trim().length === 0;
|
|
89
|
+
const objectValue = isPlainObject(rawValue);
|
|
90
|
+
const validValue = objectValue
|
|
91
|
+
&& isPositiveInteger(rawValue.limit)
|
|
92
|
+
&& isPositiveInteger(rawValue.window);
|
|
93
|
+
|
|
94
|
+
if (reserved) {
|
|
95
|
+
return {
|
|
96
|
+
rawEndpoint, rawValue, kind: 'reserved', override: null,
|
|
97
|
+
issue: 'Reserved __replace data cannot be changed or cleared by a generic JSON update. Repair the full stored value outside this editor.',
|
|
98
|
+
canEdit: false, canClear: false,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
if (blank) {
|
|
102
|
+
return {
|
|
103
|
+
rawEndpoint, rawValue, kind: objectValue ? 'repairable' : 'scalar', override: null,
|
|
104
|
+
issue: 'The stored endpoint key is empty or whitespace-only. It may only be cleared using its exact raw identity.',
|
|
105
|
+
canEdit: false, canClear: true,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
if (validValue) {
|
|
109
|
+
return {
|
|
110
|
+
rawEndpoint, rawValue, kind: 'valid',
|
|
111
|
+
override: { limit: rawValue.limit as number, window: rawValue.window as number },
|
|
112
|
+
issue: null, canEdit: true, canClear: true,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
if (objectValue) {
|
|
116
|
+
const usesDecoratorWindow = isPositiveInteger(rawValue.limit)
|
|
117
|
+
&& !Object.prototype.hasOwnProperty.call(rawValue, 'window');
|
|
118
|
+
return {
|
|
119
|
+
rawEndpoint, rawValue, kind: 'repairable', override: null,
|
|
120
|
+
issue: usesDecoratorWindow
|
|
121
|
+
? 'Legacy override has no explicit window. The server uses the endpoint decorator default; repair it to store a positive whole-minute window.'
|
|
122
|
+
: 'Invalid stored override: limit and window must both be positive integers in minutes. This entry does not create a per-key hard ceiling.',
|
|
123
|
+
canEdit: true, canClear: true,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
rawEndpoint, rawValue, kind: 'scalar', override: null,
|
|
128
|
+
issue: 'Stored override is not an object. It may be cleared, but cannot be edited as a structured limit.',
|
|
129
|
+
canEdit: false, canClear: true,
|
|
130
|
+
};
|
|
131
|
+
});
|
|
132
|
+
return {
|
|
133
|
+
isObject: true,
|
|
134
|
+
isEmpty: entries.length === 0,
|
|
135
|
+
hasUnsafe: entries.some((entry) => entry.kind !== 'valid'),
|
|
136
|
+
entries,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export interface ApiKeyRateLimitInput {
|
|
141
|
+
endpoint?: unknown;
|
|
142
|
+
limit?: unknown;
|
|
143
|
+
window?: unknown;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function parsePositiveInteger(value: unknown, label: string): number {
|
|
147
|
+
if (typeof value === 'boolean') throw new Error(`${label} must be a positive integer.`);
|
|
148
|
+
const parsed = typeof value === 'number' ? value : Number(String(value ?? '').trim());
|
|
149
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
150
|
+
throw new Error(`${label} must be a positive integer.`);
|
|
151
|
+
}
|
|
152
|
+
return parsed;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Validate normalized Add input or an immutable existing endpoint Edit. */
|
|
156
|
+
export function validateApiKeyRateLimitInput(
|
|
157
|
+
input: ApiKeyRateLimitInput,
|
|
158
|
+
options: { existing?: unknown; fixedEndpoint?: string } = {},
|
|
159
|
+
): { endpoint: string; override: ApiKeyRateLimitOverride } {
|
|
160
|
+
const adding = options.fixedEndpoint === undefined;
|
|
161
|
+
const endpoint = adding ? String(input.endpoint ?? '').trim() : options.fixedEndpoint!;
|
|
162
|
+
if (!endpoint) throw new Error('Endpoint key is required.');
|
|
163
|
+
if (endpoint === '__replace') throw new Error('__replace is reserved and cannot be used as an endpoint key.');
|
|
164
|
+
if (adding && isPlainObject(options.existing)
|
|
165
|
+
&& Object.prototype.hasOwnProperty.call(options.existing, endpoint)) {
|
|
166
|
+
throw new Error('That endpoint key already has an override. Edit the existing row instead.');
|
|
167
|
+
}
|
|
168
|
+
return {
|
|
169
|
+
endpoint,
|
|
170
|
+
override: {
|
|
171
|
+
limit: parsePositiveInteger(input.limit, 'Request limit'),
|
|
172
|
+
window: parsePositiveInteger(input.window, 'Window in minutes'),
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Build one narrow JSONField patch. `currentValue` is optional for Add; Edit
|
|
179
|
+
* passes the exact raw object so extension-owned nested siblings survive the
|
|
180
|
+
* backend's recursive merge. Null is an exact-key tombstone after django-mojo
|
|
181
|
+
* 3105. The reserved root signal is deliberately impossible to target.
|
|
182
|
+
*/
|
|
183
|
+
export function buildApiKeyLimitPatch(
|
|
184
|
+
rawEndpoint: string,
|
|
185
|
+
override: ApiKeyRateLimitOverride | null,
|
|
186
|
+
currentValue?: unknown,
|
|
187
|
+
): Record<string, unknown> {
|
|
188
|
+
if (rawEndpoint === '__replace') {
|
|
189
|
+
throw new Error('__replace is a reserved JSON update signal and cannot be edited or cleared here.');
|
|
190
|
+
}
|
|
191
|
+
if (override === null) return { [rawEndpoint]: null };
|
|
192
|
+
const limit = parsePositiveInteger(override.limit, 'Request limit');
|
|
193
|
+
const window = parsePositiveInteger(override.window, 'Window in minutes');
|
|
194
|
+
const preserved = isPlainObject(currentValue) ? currentValue : {};
|
|
195
|
+
return { [rawEndpoint]: { ...preserved, limit, window } };
|
|
196
|
+
}
|
|
197
|
+
|
|
38
198
|
interface GroupApiKeyCreateResponse extends GroupApiKeyRow {
|
|
39
199
|
/** Present on the create echo only. Never written to Query cache. */
|
|
40
200
|
token?: string;
|
|
@@ -169,6 +329,16 @@ registerGroupApiKeyPermissions([
|
|
|
169
329
|
{ name: 'view_members', label: 'View Members' },
|
|
170
330
|
{ name: 'manage_members', label: 'Manage Members' },
|
|
171
331
|
{ name: 'view_billing', label: 'View Billing' },
|
|
332
|
+
{
|
|
333
|
+
name: 'send_sms',
|
|
334
|
+
label: 'Send SMS',
|
|
335
|
+
tooltip: 'Send outbound SMS messages for this key\'s group.',
|
|
336
|
+
},
|
|
337
|
+
{
|
|
338
|
+
name: 'comms',
|
|
339
|
+
label: 'Communications',
|
|
340
|
+
tooltip: 'Broad communications access, including SMS. Prefer Send SMS when that is all the integration needs.',
|
|
341
|
+
},
|
|
172
342
|
{
|
|
173
343
|
name: 'geoip_sync',
|
|
174
344
|
label: 'GeoIP Federation Sync',
|
|
@@ -184,6 +354,60 @@ export function grantedPermissions(dict: Record<string, unknown> | null | undefi
|
|
|
184
354
|
.map(([name]) => name);
|
|
185
355
|
}
|
|
186
356
|
|
|
357
|
+
/** TagInput CSV or array to unique permission names. */
|
|
358
|
+
export function normalizeApiKeyPermissionNames(raw: unknown): string[] {
|
|
359
|
+
const values = Array.isArray(raw) ? raw : typeof raw === 'string' ? raw.split(',') : [];
|
|
360
|
+
const names: string[] = [];
|
|
361
|
+
for (const value of values) {
|
|
362
|
+
let name = String(value ?? '').trim();
|
|
363
|
+
if (name.startsWith('permissions.')) name = name.slice('permissions.'.length).trim();
|
|
364
|
+
if (!name) continue;
|
|
365
|
+
if (name === '__replace') {
|
|
366
|
+
throw new Error('Permission "__replace" is reserved and cannot be granted.');
|
|
367
|
+
}
|
|
368
|
+
if (!names.includes(name)) names.push(name);
|
|
369
|
+
}
|
|
370
|
+
return names;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Truthy stored grants that have no registered guided control. */
|
|
374
|
+
export function customApiKeyPermissionNames(
|
|
375
|
+
dict: Record<string, unknown> | null | undefined,
|
|
376
|
+
): string[] {
|
|
377
|
+
const registered = new Set(getGroupApiKeyPermissions().map((permission) => permission.name));
|
|
378
|
+
return grantedPermissions(dict).filter((name) => !registered.has(name)).sort();
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Build a merge-safe permissions patch from rendered switches and freeform
|
|
383
|
+
* tags. Only rendered switches are diffed, so protected controls hidden from
|
|
384
|
+
* this operator remain untouched. Missing previously-custom tags are revoked.
|
|
385
|
+
*/
|
|
386
|
+
export function buildApiKeyPermissionChanges(
|
|
387
|
+
current: Record<string, unknown> | null | undefined,
|
|
388
|
+
rendered: ApiKeyPermissionDef[],
|
|
389
|
+
form: Record<string, unknown>,
|
|
390
|
+
customRaw: unknown,
|
|
391
|
+
): Record<string, boolean> {
|
|
392
|
+
const currentGranted = new Set(grantedPermissions(current));
|
|
393
|
+
const changes: Record<string, boolean> = {};
|
|
394
|
+
|
|
395
|
+
for (const permission of rendered) {
|
|
396
|
+
const next = form[`permissions.${permission.name}`] === true;
|
|
397
|
+
if (currentGranted.has(permission.name) !== next) changes[permission.name] = next;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const beforeCustom = new Set(customApiKeyPermissionNames(current));
|
|
401
|
+
const afterCustom = normalizeApiKeyPermissionNames(customRaw);
|
|
402
|
+
for (const name of afterCustom) {
|
|
403
|
+
if (!currentGranted.has(name)) changes[name] = true;
|
|
404
|
+
}
|
|
405
|
+
for (const name of beforeCustom) {
|
|
406
|
+
if (!afterCustom.includes(name)) changes[name] = false;
|
|
407
|
+
}
|
|
408
|
+
return changes;
|
|
409
|
+
}
|
|
410
|
+
|
|
187
411
|
export interface WebhookSubscriptionRow {
|
|
188
412
|
id: number;
|
|
189
413
|
created: number;
|
|
@@ -15,13 +15,13 @@ export type EmailDnsMode = 'manual' | 'route53' | 'godaddy' | 'unknown';
|
|
|
15
15
|
export type SentMessageStatus = 'queued' | 'sending' | 'delivered' | 'bounced' | 'complained' | 'failed' | 'unknown';
|
|
16
16
|
|
|
17
17
|
export interface EmailDomainRow {
|
|
18
|
-
id: number; created: number; modified: number; name: string; region: string; status: EmailDomainStatus;
|
|
18
|
+
id: number; created: number | null; modified: number | null; name: string; region: string; status: EmailDomainStatus;
|
|
19
19
|
receiving_enabled: boolean; s3_inbound_bucket: string | null; s3_inbound_prefix: string; dns_mode: EmailDnsMode;
|
|
20
20
|
aws_key_masked?: string | null; aws_secret_masked?: string | null;
|
|
21
21
|
sns_topic_bounce_arn?: string | null; sns_topic_complaint_arn?: string | null; sns_topic_delivery_arn?: string | null; sns_topic_inbound_arn?: string | null;
|
|
22
22
|
}
|
|
23
23
|
export interface MailboxRow {
|
|
24
|
-
id: number; created: number; modified: number; email: string; domain: MessagingRelation | number | null;
|
|
24
|
+
id: number; created: number | null; modified: number | null; email: string; domain: MessagingRelation | number | null;
|
|
25
25
|
allow_inbound: boolean; allow_outbound: boolean; async_handler: string | null;
|
|
26
26
|
is_system_default: boolean; is_domain_default: boolean;
|
|
27
27
|
}
|
|
@@ -31,7 +31,7 @@ export interface SentMessageRow {
|
|
|
31
31
|
ses_message_id: string | null; status_reason?: string | null; body_text?: string | null; body_html?: string | null;
|
|
32
32
|
}
|
|
33
33
|
export interface EmailTemplateRow {
|
|
34
|
-
id: number; created: number; modified: number; name: string; subject_template?: string; html_template?: string | null; text_template?: string | null;
|
|
34
|
+
id: number; created: number | null; modified: number | null; name: string; subject_template?: string; html_template?: string | null; text_template?: string | null;
|
|
35
35
|
}
|
|
36
36
|
export interface PublicMessageRow {
|
|
37
37
|
id: number; created: number; modified: number; kind: 'contact_us' | 'support' | string; status: 'open' | 'closed' | string;
|
|
@@ -5,6 +5,7 @@ const object = (value: unknown): Record<string, unknown> => value && typeof valu
|
|
|
5
5
|
const text = (value: unknown, max = MAX_TEXT): string => typeof value === 'string' ? value.slice(0, max) : '';
|
|
6
6
|
const nullableText = (value: unknown, max = MAX_TEXT): string | null => value == null ? null : text(value, max);
|
|
7
7
|
const number = (value: unknown): number => Number.isFinite(Number(value)) ? Number(value) : 0;
|
|
8
|
+
const nullableNumber = (value: unknown): number | null => value != null && value !== '' && Number.isFinite(Number(value)) ? Number(value) : null;
|
|
8
9
|
const relation = (value: unknown) => {
|
|
9
10
|
if (typeof value === 'number') return value;
|
|
10
11
|
const row = object(value); if (!Number.isInteger(row.id)) return null;
|
|
@@ -14,10 +15,10 @@ const strings = (value: unknown, maxItems = 50, max = 1000): string[] => Array.i
|
|
|
14
15
|
const redactedOptionalText=(value:unknown,max=MAX_TEXT):string=>{const projected=text(value,max);return projected?redactMessagingError(projected):'';};
|
|
15
16
|
|
|
16
17
|
function safeMask(value:unknown):string|null{const masked=nullableText(value,64);if(!masked)return null;return /^(?:\*{1,60}|•{1,60})[^*•\s]{0,4}$/.test(masked)?masked:'Configured';}
|
|
17
|
-
export function sanitizeEmailDomainRow(value: EmailDomainRow): EmailDomainRow { const raw = object(value); const status=String(raw.status), dnsMode=String(raw.dns_mode); return { id: number(raw.id), created:
|
|
18
|
-
export function sanitizeMailboxRow(value: MailboxRow): MailboxRow { const raw = object(value); return { id: number(raw.id), created:
|
|
18
|
+
export function sanitizeEmailDomainRow(value: EmailDomainRow): EmailDomainRow { const raw = object(value); const status=String(raw.status), dnsMode=String(raw.dns_mode); return { id: number(raw.id), created: nullableNumber(raw.created), modified: nullableNumber(raw.modified), name: text(raw.name, 253), region: text(raw.region, 64), status: (['pending','verified','ready','missing'].includes(status)?status:'unknown') as EmailDomainRow['status'], receiving_enabled: Boolean(raw.receiving_enabled), s3_inbound_bucket: nullableText(raw.s3_inbound_bucket, 255), s3_inbound_prefix: text(raw.s3_inbound_prefix, 255), dns_mode: (['manual','route53','godaddy'].includes(dnsMode)?dnsMode:'unknown') as EmailDomainRow['dns_mode'], aws_key_masked: safeMask(raw.aws_key_masked), aws_secret_masked: safeMask(raw.aws_secret_masked), sns_topic_bounce_arn: nullableText(raw.sns_topic_bounce_arn, 512), sns_topic_complaint_arn: nullableText(raw.sns_topic_complaint_arn, 512), sns_topic_delivery_arn: nullableText(raw.sns_topic_delivery_arn, 512), sns_topic_inbound_arn: nullableText(raw.sns_topic_inbound_arn, 512) }; }
|
|
19
|
+
export function sanitizeMailboxRow(value: MailboxRow): MailboxRow { const raw = object(value); return { id: number(raw.id), created: nullableNumber(raw.created), modified: nullableNumber(raw.modified), email: text(raw.email, 320), domain: relation(raw.domain), allow_inbound: Boolean(raw.allow_inbound), allow_outbound: Boolean(raw.allow_outbound), async_handler: nullableText(raw.async_handler, 255), is_system_default: Boolean(raw.is_system_default), is_domain_default: Boolean(raw.is_domain_default) }; }
|
|
19
20
|
export function sanitizeSentMessageRow(value: SentMessageRow): SentMessageRow { const raw = object(value); const reason=nullableText(raw.status_reason,1000); return { id: number(raw.id), created: number(raw.created), modified: number(raw.modified), mailbox: relation(raw.mailbox), to_addresses: strings(raw.to_addresses, 50, 320), cc_addresses: strings(raw.cc_addresses, 50, 320), bcc_addresses: strings(raw.bcc_addresses, 50, 320), subject: text(raw.subject, 998), status: (['queued','sending','delivered','bounced','complained','failed','unknown'].includes(String(raw.status)) ? String(raw.status) : 'unknown') as SentMessageRow['status'], ses_message_id: nullableText(raw.ses_message_id, 255), status_reason: reason?redactMessagingError(reason):null, body_text: nullableText(raw.body_text, 100_000), body_html: nullableText(raw.body_html, 200_000) }; }
|
|
20
|
-
export function sanitizeTemplateRow(value: EmailTemplateRow): EmailTemplateRow { const raw = object(value); return { id: number(raw.id), created:
|
|
21
|
+
export function sanitizeTemplateRow(value: EmailTemplateRow): EmailTemplateRow { const raw = object(value); return { id: number(raw.id), created: nullableNumber(raw.created), modified: nullableNumber(raw.modified), name: text(raw.name, 255), subject_template: text(raw.subject_template, 998), html_template: nullableText(raw.html_template, 200_000), text_template: nullableText(raw.text_template, 100_000) }; }
|
|
21
22
|
const PUBLIC_META = new Set(['company','category','severity','referrer','landing_page','utm_source','utm_medium','utm_campaign','utm_term','utm_content']);
|
|
22
23
|
export function sanitizePublicMessageRow(value: PublicMessageRow): PublicMessageRow { const raw = object(value); const source = object(raw.metadata); const metadata: Record<string,string> = {}; for (const [key, val] of Object.entries(source)) if (PUBLIC_META.has(key) && typeof val === 'string') metadata[key] = text(val, 500); return { id: number(raw.id), created: number(raw.created), modified: number(raw.modified), kind: text(raw.kind, 40), status: text(raw.status, 40), name: text(raw.name, 255), email: text(raw.email, 320), subject: text(raw.subject, 998), group: relation(raw.group), ...(typeof raw.message === 'string' ? { message: text(raw.message, 50_000) } : {}), ...(Object.keys(metadata).length ? { metadata } : {}), metadata_withheld: Object.keys(source).some((key) => !PUBLIC_META.has(key)), ip_address: nullableText(raw.ip_address, 64), user_agent: nullableText(raw.user_agent, 1000) }; }
|
|
23
24
|
|
package/src/client/mock.ts
CHANGED
|
@@ -639,6 +639,20 @@ function buildGroupApiKeys(): MockGroupApiKey[] {
|
|
|
639
639
|
{ id: 201, group: 1, user: 1, created: now - 80 * 86400, modified: now - 2 * 86400, name: 'Acme deploy', is_active: true, permissions: { member: true, view_metrics: true }, limits: { assess: { limit: 500, window: 60 } }, last_used: now - 1800, expires_at: null, metadata: { owner: 'platform' }, override_user: false, token: 'mock_gk_acme_deploy' },
|
|
640
640
|
{ id: 202, group: 1, user: null, created: now - 30 * 86400, modified: now - 4 * 86400, name: 'Webhook worker', is_active: true, permissions: { manage_webhooks: true }, limits: {}, last_used: now - 86400, expires_at: now + 180 * 86400, metadata: {}, override_user: false, token: 'mock_gk_webhook_worker' },
|
|
641
641
|
{ id: 203, group: 2, user: null, created: now - 120 * 86400, modified: now - 20 * 86400, name: 'Legacy importer', is_active: false, permissions: { member: true }, limits: {}, last_used: now - 40 * 86400, expires_at: now + 10 * 86400, metadata: {}, override_user: false, token: 'mock_gk_legacy_importer' },
|
|
642
|
+
{
|
|
643
|
+
id: 204, group: 1, user: null, created: now - 150 * 86400, modified: now - 30 * 86400,
|
|
644
|
+
name: 'Legacy mixed limits', is_active: true, permissions: { view_logs: true },
|
|
645
|
+
limits: {
|
|
646
|
+
orders: { limit: 120, window: 5, extension: { burst: 20, source: 'legacy' } },
|
|
647
|
+
partial: { limit: 8, extension: { keep: true } },
|
|
648
|
+
scalar: 'legacy-unstructured',
|
|
649
|
+
'': { limit: 2, window: 1 },
|
|
650
|
+
' ': { window: 3 },
|
|
651
|
+
__replace: { limit: 999, window: 1 },
|
|
652
|
+
},
|
|
653
|
+
last_used: now - 3 * 86400, expires_at: null, metadata: { owner: 'legacy' },
|
|
654
|
+
override_user: false, token: 'mock_gk_legacy_mixed',
|
|
655
|
+
},
|
|
642
656
|
];
|
|
643
657
|
}
|
|
644
658
|
|
|
@@ -4566,6 +4580,25 @@ function mergeDicts(existing: Record<string, unknown>, incoming: Record<string,
|
|
|
4566
4580
|
return out;
|
|
4567
4581
|
}
|
|
4568
4582
|
|
|
4583
|
+
/** ApiKey.limits narrow merge: null deletes one exact endpoint identity. */
|
|
4584
|
+
function mergeApiKeyLimits(existing: Record<string, unknown>, incoming: Record<string, unknown>): Record<string, unknown> {
|
|
4585
|
+
const out = { ...existing };
|
|
4586
|
+
for (const [key, value] of Object.entries(incoming)) {
|
|
4587
|
+
// django-mojo consumes this root key as a replace signal before merge.
|
|
4588
|
+
// The mock intentionally offers no destructive replacement surface.
|
|
4589
|
+
if (key === '__replace') continue;
|
|
4590
|
+
if (value === null) {
|
|
4591
|
+
delete out[key];
|
|
4592
|
+
continue;
|
|
4593
|
+
}
|
|
4594
|
+
const previous = out[key];
|
|
4595
|
+
out[key] = isPlainObject(previous) && isPlainObject(value)
|
|
4596
|
+
? mergeDicts(previous, value)
|
|
4597
|
+
: value;
|
|
4598
|
+
}
|
|
4599
|
+
return out;
|
|
4600
|
+
}
|
|
4601
|
+
|
|
4569
4602
|
const LOGIN_METHODS = new Set(['password', 'sms', 'passkey', 'magic', 'google', 'apple', 'github']);
|
|
4570
4603
|
const REGISTRATION_METHODS = new Set(['password', 'google', 'apple', 'github']);
|
|
4571
4604
|
const DEFAULT_AUTH_CONFIG: Record<string, unknown> = {
|
|
@@ -9565,7 +9598,7 @@ export async function mockFetch(path: string, opts: MockFetchOpts): Promise<unkn
|
|
|
9565
9598
|
if ('name' in opts.body) row.name = String(opts.body.name ?? '');
|
|
9566
9599
|
if ('is_active' in opts.body) row.is_active = Boolean(opts.body.is_active);
|
|
9567
9600
|
if (isPlainObject(opts.body.permissions)) row.permissions = mergeDicts(row.permissions, opts.body.permissions);
|
|
9568
|
-
if (isPlainObject(opts.body.limits)) row.limits =
|
|
9601
|
+
if (isPlainObject(opts.body.limits)) row.limits = mergeApiKeyLimits(row.limits, opts.body.limits);
|
|
9569
9602
|
if (isPlainObject(opts.body.metadata)) row.metadata = mergeDicts(row.metadata, opts.body.metadata);
|
|
9570
9603
|
row.modified = Math.floor(Date.now() / 1000);
|
|
9571
9604
|
}
|