portal-mojo 0.2.0 → 0.2.1
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 +64 -0
- package/package.json +1 -1
- package/src/admin/credentials/api-key-rate-limits.tsx +284 -0
- package/src/admin/credentials/group-api-keys.tsx +41 -18
- package/src/admin/credentials/index.ts +1 -0
- package/src/admin/credentials/models.ts +160 -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
|
|
|
@@ -85,6 +89,66 @@ Registration replaces the same permission name and appends new names. Mounted
|
|
|
85
89
|
editors subscribe to changes. `grantPermissions` gates the control itself; the
|
|
86
90
|
backend remains authoritative on every save.
|
|
87
91
|
|
|
92
|
+
### Rate-limit overrides
|
|
93
|
+
|
|
94
|
+
Group API keys expose the raw django-mojo JSONField shape without normalizing
|
|
95
|
+
endpoint identity:
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
const limits = {
|
|
99
|
+
orders: { limit: 500, window: 5, extension_owned: { burst: 20 } },
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const state = readApiKeyRateLimits(limits);
|
|
103
|
+
const patch = buildApiKeyLimitPatch(
|
|
104
|
+
'orders',
|
|
105
|
+
{ limit: 750, window: 10 },
|
|
106
|
+
limits.orders, // preserves extension_owned and every other nested sibling
|
|
107
|
+
);
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
`limit` and `window` are positive integers with no fixed maximum. `window` is
|
|
111
|
+
always measured in **minutes** in `ApiKey.limits`; django-mojo converts it to
|
|
112
|
+
seconds at enforcement time. `ApiKeyLimitsSummary` is the bounded card/table
|
|
113
|
+
presentation. `ApiKeyRateLimitsEditor` is the controlled detail editor and
|
|
114
|
+
keeps saves on the rejecting `GroupApiKeyModel.useSave()` path.
|
|
115
|
+
|
|
116
|
+
This UI depends on django-mojo 1.20.0 / item 3105. A genuinely empty object is
|
|
117
|
+
shown as **Unlimited (default)** because ordinary API-key throughput has no
|
|
118
|
+
built-in hard ceiling. Strict endpoint limits, a positive decorator fallback,
|
|
119
|
+
or an enabled deployment ceiling may still apply. When safe overrides exist,
|
|
120
|
+
unlisted ordinary endpoint buckets remain unlimited. Any malformed top-level
|
|
121
|
+
or nested value shows **Review required** instead of either safe claim.
|
|
122
|
+
|
|
123
|
+
Add trims the new endpoint name and rejects blank, duplicate, and reserved
|
|
124
|
+
`__replace` names. Edit keeps the stored endpoint identity immutable. Clear
|
|
125
|
+
sends one narrow null tombstone, for example `{limits: {orders: null}}`; the
|
|
126
|
+
authoritative complete row returned by django-mojo replaces Query state while
|
|
127
|
+
every unrelated endpoint remains intact.
|
|
128
|
+
|
|
129
|
+
Legacy data is lossless:
|
|
130
|
+
|
|
131
|
+
- empty and whitespace-only keys are quoted distinctly and clear only by their
|
|
132
|
+
exact raw key;
|
|
133
|
+
- scalar entries may be cleared but not edited as structured limits;
|
|
134
|
+
- partial object entries may be repaired, preserving extra nested siblings. A
|
|
135
|
+
positive limit with no `window` currently uses the endpoint decorator's
|
|
136
|
+
default window; other malformed/non-positive entries fail open;
|
|
137
|
+
- valid entries with extra siblings retain them on edit;
|
|
138
|
+
- a stored `__replace` root key is read-only. django-mojo consumes that name as
|
|
139
|
+
a JSON replacement signal, so generic edit/clear controls would be dishonest;
|
|
140
|
+
repair the full stored value outside this editor.
|
|
141
|
+
|
|
142
|
+
Mutation controls are fail-closed behind the supplied `PermSpec`. The global
|
|
143
|
+
detail uses `GLOBAL_CREDENTIAL_PERMS`; embedded group surfaces default to
|
|
144
|
+
`GROUP_CREDENTIAL_PERMS`. Denied viewers receive no mutation controls or extra
|
|
145
|
+
credential queries. Limit mutations never request the token graph and retain
|
|
146
|
+
the existing pre-cache token scrubber.
|
|
147
|
+
|
|
148
|
+
Pitfalls: do not clone the raw object into a typed-only map, trim an existing
|
|
149
|
+
key, send `__replace`, label malformed data unlimited, measure windows in
|
|
150
|
+
seconds, or replace the full JSONField to clear one endpoint.
|
|
151
|
+
|
|
88
152
|
## Webhooks
|
|
89
153
|
|
|
90
154
|
```tsx
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "portal-mojo",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
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
|
+
}
|
|
@@ -14,9 +14,10 @@ import {
|
|
|
14
14
|
GroupApiKeyModel, fetchApiKeyToken,
|
|
15
15
|
getGroupApiKeyPermissions, grantedPermissions,
|
|
16
16
|
groupApiKeyPermissionsVersion, subscribeGroupApiKeyPermissions,
|
|
17
|
-
useCreateGroupApiKey,
|
|
17
|
+
readApiKeyRateLimits, useCreateGroupApiKey,
|
|
18
18
|
type ApiKeyPermissionDef, type CredentialGroup, type GroupApiKeyRow,
|
|
19
19
|
} from './models';
|
|
20
|
+
import { ApiKeyLimitsSummary, ApiKeyRateLimitsEditor } from './api-key-rate-limits';
|
|
20
21
|
import { showSecretDialog } from './secret-dialog';
|
|
21
22
|
|
|
22
23
|
function groupLabel(group: GroupApiKeyRow['group']): string {
|
|
@@ -257,6 +258,7 @@ function ApiKeyCard({ row, actions }: {
|
|
|
257
258
|
? permissions.map((permission) => <Badge key={permission} tone="info">{permission}</Badge>)
|
|
258
259
|
: <span className="dim-italic">No permissions granted</span>}
|
|
259
260
|
</div>
|
|
261
|
+
<ApiKeyLimitsSummary limits={row.limits} />
|
|
260
262
|
<div className="dim ga-card-meta">
|
|
261
263
|
Last used <b>{fmt.relative(row.last_used, 'never')}</b> · Created {fmt.date(row.created)}
|
|
262
264
|
{row.expires_at != null && <> · Expires {fmt.date(row.expires_at)}</>}
|
|
@@ -315,12 +317,18 @@ export function GroupApiKeysSection({ group, permission = GROUP_CREDENTIAL_PERMS
|
|
|
315
317
|
return <GroupApiKeysContent group={group} permission={permission} />;
|
|
316
318
|
}
|
|
317
319
|
|
|
318
|
-
|
|
320
|
+
function AuthorizedGroupApiKeyDetail({ id, onClose }: { id: number; onClose: () => void }) {
|
|
319
321
|
const { data: row, isPending, error } = GroupApiKeyModel.useOne(id);
|
|
320
322
|
const actions = useGroupApiKeyActions(GLOBAL_CREDENTIAL_PERMS);
|
|
321
323
|
if (isPending) return <div className="modal-pad dim">Loading API key…</div>;
|
|
322
324
|
if (!row || error) return <div className="modal-pad text-bad">{error?.message ?? 'API key not found'}</div>;
|
|
323
325
|
const permissions = grantedPermissions(row.permissions);
|
|
326
|
+
const limits = readApiKeyRateLimits(row.limits);
|
|
327
|
+
const limitsChip = limits.isEmpty
|
|
328
|
+
? { text: 'Unlimited (default)', tone: 'muted' as const }
|
|
329
|
+
: limits.hasUnsafe || !limits.isObject
|
|
330
|
+
? { text: 'Limits review required', tone: 'warning' as const }
|
|
331
|
+
: { text: `${limits.entries.length} rate limit${limits.entries.length === 1 ? '' : 's'}`, tone: 'info' as const };
|
|
324
332
|
return (
|
|
325
333
|
<DetailView
|
|
326
334
|
icon="bi-key"
|
|
@@ -329,6 +337,7 @@ export function GroupApiKeyDetail({ id, onClose }: { id: number; onClose: () =>
|
|
|
329
337
|
chips={[
|
|
330
338
|
{ text: row.is_active ? 'Active' : 'Inactive', tone: row.is_active ? 'success' : 'muted' },
|
|
331
339
|
{ text: `${permissions.length} grants`, tone: 'info' },
|
|
340
|
+
limitsChip,
|
|
332
341
|
]}
|
|
333
342
|
sections={[
|
|
334
343
|
{
|
|
@@ -339,22 +348,24 @@ export function GroupApiKeyDetail({ id, onClose }: { id: number; onClose: () =>
|
|
|
339
348
|
<FlatRow label="Created">{fmt.datetime(row.created)}</FlatRow>
|
|
340
349
|
<FlatRow label="Last used">{fmt.relative(row.last_used, 'never')}</FlatRow>
|
|
341
350
|
<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
|
-
|
|
351
|
+
{actions.canManage && (
|
|
352
|
+
<div className="ga-toolbar" style={{ marginTop: 16 }}>
|
|
353
|
+
<button className="btn btn-compact" onClick={() => void actions.editKey(row)}>
|
|
354
|
+
<i className="bi bi-pencil" /> Edit
|
|
355
|
+
</button>
|
|
356
|
+
<button className="btn btn-compact" onClick={() => void actions.revealToken(row)}>
|
|
357
|
+
<i className="bi bi-eye" /> Reveal audited token
|
|
358
|
+
</button>
|
|
359
|
+
<ArmedButton
|
|
360
|
+
className="btn-compact"
|
|
361
|
+
label="Delete"
|
|
362
|
+
armedLabel="Click again — delete now"
|
|
363
|
+
onConfirm={async () => {
|
|
364
|
+
if (await actions.deleteKey(row)) onClose();
|
|
365
|
+
}}
|
|
366
|
+
/>
|
|
367
|
+
</div>
|
|
368
|
+
)}
|
|
358
369
|
</>
|
|
359
370
|
),
|
|
360
371
|
},
|
|
@@ -371,6 +382,11 @@ export function GroupApiKeyDetail({ id, onClose }: { id: number; onClose: () =>
|
|
|
371
382
|
</>
|
|
372
383
|
),
|
|
373
384
|
},
|
|
385
|
+
{
|
|
386
|
+
key: 'limits', label: 'Rate Limits', icon: 'bi-speedometer2', render: () => (
|
|
387
|
+
<ApiKeyRateLimitsEditor row={row} permission={GLOBAL_CREDENTIAL_PERMS} />
|
|
388
|
+
),
|
|
389
|
+
},
|
|
374
390
|
]}
|
|
375
391
|
initialSection="permissions"
|
|
376
392
|
onClose={onClose}
|
|
@@ -378,6 +394,12 @@ export function GroupApiKeyDetail({ id, onClose }: { id: number; onClose: () =>
|
|
|
378
394
|
);
|
|
379
395
|
}
|
|
380
396
|
|
|
397
|
+
export function GroupApiKeyDetail({ id, onClose }: { id: number; onClose: () => void }) {
|
|
398
|
+
const { can } = useCan(GLOBAL_CREDENTIAL_PERMS);
|
|
399
|
+
if (!can) return <div className="modal-pad dim">API key details are unavailable for this account.</div>;
|
|
400
|
+
return <AuthorizedGroupApiKeyDetail id={id} onClose={onClose} />;
|
|
401
|
+
}
|
|
402
|
+
|
|
381
403
|
const API_KEY_COLUMNS: Column<GroupApiKeyRow>[] = [
|
|
382
404
|
{
|
|
383
405
|
key: 'name', label: 'Key', sortable: true, hideable: false, render: (row) => (
|
|
@@ -390,6 +412,7 @@ const API_KEY_COLUMNS: Column<GroupApiKeyRow>[] = [
|
|
|
390
412
|
{ key: 'group', label: 'Group', render: (row) => groupLabel(row.group) },
|
|
391
413
|
{ key: 'is_active', label: 'Status', render: (row) => <Badge tone={row.is_active ? 'success' : 'muted'}>{row.is_active ? 'Active' : 'Inactive'}</Badge> },
|
|
392
414
|
{ key: 'permissions', label: 'Grants', align: 'center', render: (row) => String(grantedPermissions(row.permissions).length) },
|
|
415
|
+
{ key: 'limits', label: 'Limits', hideable: false, render: (row) => <ApiKeyLimitsSummary limits={row.limits} /> },
|
|
393
416
|
{ key: 'last_used', label: 'Last used', sortable: true, render: (row) => fmt.relative(row.last_used, 'never') },
|
|
394
417
|
{ key: 'created', label: 'Created', sortable: true, render: (row) => fmt.date(row.created) },
|
|
395
418
|
];
|
|
@@ -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;
|
|
@@ -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
|
}
|