@fonderie/admin 1.1.0 → 1.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/README.md +56 -0
- package/brain/outcomes.md +65 -0
- package/brain/signatures.md +3 -1
- package/dist/index.cjs +1135 -82
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +21 -1
- package/dist/index.d.ts +21 -1
- package/dist/index.js +1141 -80
- package/dist/index.js.map +1 -1
- package/dist/migrations/sql/003_admin_operators.sql +55 -0
- package/dist/ui/app.global.js +13 -8
- package/package.json +3 -1
package/README.md
CHANGED
|
@@ -173,6 +173,62 @@ A refused request is still logged — the caller learns nothing from a 404,
|
|
|
173
173
|
and you learn that someone went looking. `GET /_admin/manifest` reports the
|
|
174
174
|
binding as `admin.host`, so the Configuration page can show it.
|
|
175
175
|
|
|
176
|
+
## Operators: people sign in, tokens are for machines
|
|
177
|
+
|
|
178
|
+
A pasted token is a shared secret with no name on it. With a `store`, the
|
|
179
|
+
console instead has **operator accounts**: email, password, and a mandatory
|
|
180
|
+
authenticator app, with backup codes as the fallback.
|
|
181
|
+
|
|
182
|
+
```ts
|
|
183
|
+
new AdminModule({
|
|
184
|
+
adminToken: process.env.ADMIN_TOKEN, // machines (CLI, CI) + the break-glass
|
|
185
|
+
// operatorKey: optional — see below
|
|
186
|
+
store,
|
|
187
|
+
ui: true,
|
|
188
|
+
});
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
- **No registration.** The first operator is claimed once, on the console's
|
|
192
|
+
own sign-in page: paste the root `adminToken`, then create your account. Every other operator is invited
|
|
193
|
+
by an Owner: a single-use link that expires, where they set a password and
|
|
194
|
+
scan the QR code before anything opens.
|
|
195
|
+
- **Two factors, always.** Password, then a six-digit code (RFC 6238). Ten
|
|
196
|
+
single-use backup codes are shown once at setup. A code cannot be replayed
|
|
197
|
+
within its window.
|
|
198
|
+
- **A fresh code for dangerous actions.** Revealing a secret, minting a token
|
|
199
|
+
or link, applying a migration and deleting anything ask for a code from the
|
|
200
|
+
last five minutes. The console prompts and retries the action for you.
|
|
201
|
+
- **Sessions** are an HttpOnly, SameSite=Strict cookie (`__Host-` over
|
|
202
|
+
HTTPS), 30 minutes idle and 12 hours at most, rotated at every privilege
|
|
203
|
+
change. Cookie-authenticated writes must come from this origin.
|
|
204
|
+
- **Brute force** locks the account after 5 failures, doubling from a minute
|
|
205
|
+
to an hour; one address is also limited across accounts. Error messages do
|
|
206
|
+
not say which part was wrong.
|
|
207
|
+
- **Recovery without email.** A lost phone uses a backup code. Lost both, or a
|
|
208
|
+
forgotten password: another Owner creates a recovery link (new password, new
|
|
209
|
+
authenticator, signed out everywhere). Every Owner locked out: the root token
|
|
210
|
+
does it from the terminal —
|
|
211
|
+
|
|
212
|
+
```bash
|
|
213
|
+
FONDERIE_ADMIN_URL=https://api.example.com FONDERIE_ADMIN_TOKEN=$ADMIN_TOKEN \
|
|
214
|
+
npx fonderie admin operator recover you@example.com
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Access levels map to scopes: **Read only** (`read`), **Editor** (`read`,
|
|
218
|
+
`write`), **Owner** (`read`, `write`, `secrets` — and operators and tokens).
|
|
219
|
+
Operator actions are logged under `operator:<email>`, which a client header
|
|
220
|
+
cannot override.
|
|
221
|
+
|
|
222
|
+
**Nothing to configure.** First sign-in on the console is the admin token
|
|
223
|
+
alone; the page then asks you to create your account and set up an
|
|
224
|
+
authenticator. `operatorKey` is optional hardening: it encrypts authenticator
|
|
225
|
+
secrets at rest so a database leak alone does not yield them (without it they
|
|
226
|
+
are stored as-is — a leaked one still needs the operator's password). Adding
|
|
227
|
+
it later is safe — existing secrets keep working and are sealed at the next
|
|
228
|
+
enrollment. Changing it once set invalidates every enrolled
|
|
229
|
+
authenticator. `operators: false` turns the sign-in routes off and brings
|
|
230
|
+
back the token gate.
|
|
231
|
+
|
|
176
232
|
## Scoped tokens
|
|
177
233
|
|
|
178
234
|
The `adminToken` you configure is the **root**: every scope, and the only
|
package/brain/outcomes.md
CHANGED
|
@@ -9,6 +9,21 @@ downloading tarballs.
|
|
|
9
9
|
|
|
10
10
|
## Database tables (after all migrations)
|
|
11
11
|
|
|
12
|
+
### `fonderie_admin_invites`
|
|
13
|
+
|
|
14
|
+
```sql
|
|
15
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid()
|
|
16
|
+
kind TEXT NOT NULL
|
|
17
|
+
token_hash TEXT NOT NULL UNIQUE
|
|
18
|
+
email TEXT NOT NULL
|
|
19
|
+
scopes TEXT[] NOT NULL DEFAULT '{}'
|
|
20
|
+
operator_id UUID REFERENCES fonderie_admin_operators(id) ON DELETE CASCADE
|
|
21
|
+
created_by TEXT NOT NULL
|
|
22
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
23
|
+
expires_at TIMESTAMPTZ NOT NULL
|
|
24
|
+
used_at TIMESTAMPTZ
|
|
25
|
+
```
|
|
26
|
+
|
|
12
27
|
### `fonderie_admin_log`
|
|
13
28
|
|
|
14
29
|
```sql
|
|
@@ -25,6 +40,41 @@ request_id TEXT
|
|
|
25
40
|
client_ip TEXT
|
|
26
41
|
```
|
|
27
42
|
|
|
43
|
+
### `fonderie_admin_operators`
|
|
44
|
+
|
|
45
|
+
```sql
|
|
46
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid()
|
|
47
|
+
email TEXT NOT NULL UNIQUE
|
|
48
|
+
name TEXT
|
|
49
|
+
password_hash TEXT NOT NULL
|
|
50
|
+
scopes TEXT[] NOT NULL
|
|
51
|
+
totp_secret TEXT
|
|
52
|
+
totp_confirmed_at TIMESTAMPTZ
|
|
53
|
+
totp_last_step BIGINT
|
|
54
|
+
backup_codes TEXT[] NOT NULL DEFAULT '{}'
|
|
55
|
+
failed_attempts INTEGER NOT NULL DEFAULT 0
|
|
56
|
+
locked_until TIMESTAMPTZ
|
|
57
|
+
created_by TEXT NOT NULL
|
|
58
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
59
|
+
last_login_at TIMESTAMPTZ
|
|
60
|
+
disabled_at TIMESTAMPTZ
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### `fonderie_admin_sessions`
|
|
64
|
+
|
|
65
|
+
```sql
|
|
66
|
+
id_hash TEXT PRIMARY KEY
|
|
67
|
+
operator_id UUID NOT NULL REFERENCES fonderie_admin_operators(id) ON DELETE CASCADE
|
|
68
|
+
stage TEXT NOT NULL
|
|
69
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
70
|
+
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
71
|
+
expires_at TIMESTAMPTZ NOT NULL
|
|
72
|
+
step_up_at TIMESTAMPTZ
|
|
73
|
+
client_ip TEXT
|
|
74
|
+
user_agent TEXT
|
|
75
|
+
-- INDEX fonderie_admin_sessions_operator (operator_id)
|
|
76
|
+
```
|
|
77
|
+
|
|
28
78
|
### `fonderie_admin_tokens`
|
|
29
79
|
|
|
30
80
|
```sql
|
|
@@ -46,6 +96,11 @@ Raw SQL ships in `node_modules/@fonderie/admin/dist/migrations/sql/` — read it
|
|
|
46
96
|
| Method | Path | Middleware chain (auth / validation / handler) |
|
|
47
97
|
|---|---|---|
|
|
48
98
|
| GET | `/_admin` | `async () => setApiResponse( HTTP.OK, 'ADMIN_ATTENTION', 'What needs attention', attention(app, await doctor()), )` |
|
|
99
|
+
| GET | `/_admin/access/operators` | `[async () => setApiResponse(HTTP.OK, 'OPERATORS', 'Operators', await listOperators(store))] → 'read'` |
|
|
100
|
+
| PUT | `/_admin/access/operators/:id` | `[ validate(updateOperatorSchema), async (ctx) => { const b = body(ctx); const op = await findOperator(store, { id: idOf(ctx) }); if (!op) return setApiResponse(HTTP.NOT_FOUND, 'NOT_FOUND', 'No such operator'); const scopes = b['scopes'] === undefined ? op.scopes : validScopes(b['scopes']); if (!scopes) return setApiResponse( HTTP.UNPROCESSABLE, 'INVALID', `scopes must be a non-empty list of ${SCOPES.join(', ')}`, ); if ( ctx.meta['adminOperator'] === op.email && !scopes.includes('secrets') && op.scopes.includes('secrets') ) { return setApiResponse( HTTP.CONFLICT, 'SELF_DEMOTION', 'You cannot remove your own highest scope. Ask another operator.', ); } const disabled = b['disabled']; if (disabled === true && ctx.meta['adminOperator'] === op.email) { return setApiResponse( HTTP.CONFLICT, 'SELF_DISABLE', 'You cannot disable yourself. Ask another operator.', ); } const [row] = await store.query<IOperatorRow>( `UPDATE fonderie_admin_operators SET scopes = $2, name = coalesce($3, name), disabled_at = CASE WHEN $4::boolean IS NULL THEN disabled_at WHEN $4 THEN coalesce(disabled_at, now()) ELSE NULL END WHERE id = $1 RETURNING id, email, name, password_hash AS "passwordHash", scopes, totp_secret AS "totpSecret", totp_confirmed_at AS "totpConfirmedAt", totp_last_step AS "totpLastStep", backup_codes AS "backupCodes", failed_attempts AS "failedAttempts", locked_until AS "lockedUntil", created_by AS "createdBy", created_at AS "createdAt", last_login_at AS "lastLoginAt", disabled_at AS "disabledAt"`, [ op.id, scopes, typeof b['name'] === 'string' ? b['name'] : null, typeof disabled === 'boolean' ? disabled : null, ], ); if (!row) return setApiResponse(HTTP.NOT_FOUND, 'NOT_FOUND', 'No such operator'); // Any change of rights ends their sessions: new rights apply at next sign-in. await deleteOperatorSessions(store, op.id); return setApiResponse( HTTP.OK, 'OPERATOR_UPDATED', 'Operator updated', publicOperator(row), ); }, ] → 'root'` |
|
|
101
|
+
| POST | `/_admin/access/operators/:id/recovery` | `[ async (ctx) => { const op = await findOperator(store, { id: idOf(ctx) }); if (!op) return setApiResponse(HTTP.NOT_FOUND, 'NOT_FOUND', 'No such operator'); await deleteOperatorSessions(store, op.id); const l = await createLink(store, { kind: 'recovery', email: op.email, operatorId: op.id, createdBy: actorOf(ctx), hours: RECOVERY_HOURS, }); return setApiResponse(HTTP.CREATED, 'RECOVERY_CREATED', 'Recovery link — shown once', { id: l.id, email: op.email, expiresAt: l.expiresAt, token: l.token, url: link(ctx, l.token), }); }, ] → 'root'` |
|
|
102
|
+
| POST | `/_admin/access/operators/invites` | `[ validate(inviteSchema), async (ctx) => { const b = body(ctx); const email = normalizeEmail(str(b['email'])); if (!EMAIL_RE.test(email)) return setApiResponse(HTTP.UNPROCESSABLE, 'INVALID', 'Enter a valid email address.'); const scopes = validScopes(b['scopes']); if (!scopes) return setApiResponse( HTTP.UNPROCESSABLE, 'INVALID', `scopes must be a non-empty list of ${SCOPES.join(', ')}`, ); if (await findOperator(store, { email })) return setApiResponse( HTTP.CONFLICT, 'ALREADY_OPERATOR', 'That email is already an operator.', ); const hours = Number.isInteger(b['expiresInHours']) ? Math.min(Math.max(b['expiresInHours'] as number, 1), 336) : INVITE_HOURS; const l = await createLink(store, { kind: 'invite', email, scopes, createdBy: actorOf(ctx), hours, }); return setApiResponse(HTTP.CREATED, 'INVITE_CREATED', 'Invite link — shown once', { id: l.id, email, scopes, expiresAt: l.expiresAt, token: l.token, url: link(ctx, l.token), }); }, ] → 'root'` |
|
|
103
|
+
| DELETE | `/_admin/access/operators/links/:id` | `[ async (ctx) => { const rows = await store.query( `UPDATE fonderie_admin_invites SET used_at = now() WHERE id = $1 AND used_at IS NULL RETURNING id`, [idOf(ctx)], ); return rows.length ? setApiResponse(HTTP.OK, 'LINK_REVOKED', 'Link revoked') : setApiResponse(HTTP.NOT_FOUND, 'NOT_FOUND', 'No such live link'); }, ] → 'root'` |
|
|
49
104
|
| GET | `/_admin/access/tokens` | `async () => setApiResponse( HTTP.OK, 'ADMIN_TOKENS', 'Admin tokens', tokensReport(app, this.name, store ? await listTokens(store) : null), )` |
|
|
50
105
|
| POST | `/_admin/access/tokens` | `[ validate(issueTokenSchema), async (ctx) => { const body = ctx.meta['body'] as { name: string; scopes: AdminScope[]; expiresInDays?: number; }; const createdBy = ctx.request.headers.get('x-actor') || 'admin-token'; const { token: plaintext, record } = await issueToken(store, { ...body, createdBy, }); // The plaintext is returned once and never stored. return setApiResponse(HTTP.CREATED, 'TOKEN_ISSUED', 'Token issued — shown once', { token: plaintext, ...record, }); }, ]` |
|
|
51
106
|
| DELETE | `/_admin/access/tokens/:id` | `[ async (ctx) => { const ok = await revokeToken(store, ctx.meta.params?.['id'] ?? ''); return ok ? setApiResponse(HTTP.OK, 'TOKEN_REVOKED', 'Token revoked') : setApiResponse(HTTP.NOT_FOUND, 'NOT_FOUND', 'No such live token'); }, ]` |
|
|
@@ -56,3 +111,13 @@ Raw SQL ships in `node_modules/@fonderie/admin/dist/migrations/sql/` — read it
|
|
|
56
111
|
| GET | `/_admin/migrations` | `[ async () => setApiResponse( HTTP.OK, 'MIGRATIONS', 'Pending migrations by module', await migrationsReport(store, migrationSets), ), ]` |
|
|
57
112
|
| POST | `/_admin/migrations/:module/apply` | `[ validate(applyMigrationsSchema), async (ctx) => { const { expect } = ctx.meta['body'] as { expect: string[] }; const name = ctx.meta.params?.['module'] ?? ''; const out = await applyModuleMigrations(store, migrationSets, name, expect); if (out.ok) { return setApiResponse( HTTP.OK, out.reason, out.reason === 'MIGRATIONS_APPLIED' ? `Applied. ${out.module.pending.length} still pending in ${name}.` : `${name} is already up to date`, out.module, ); } switch (out.reason) { case 'NOT_FOUND': return setApiResponse( HTTP.NOT_FOUND, 'NOT_FOUND', `No migration set named "${name}"`, ); case 'MIGRATIONS_OUT_OF_ORDER': return setApiResponse( HTTP.CONFLICT, out.reason, `Apply "${out.blockedBy}" first — it runs before "${name}" and is behind.`, { blockedBy: out.blockedBy }, ); case 'MIGRATIONS_CHANGED': return setApiResponse( HTTP.CONFLICT, out.reason, 'What is pending changed since you looked. Refresh and read it again.', { expected: out.expected, actual: out.actual }, ); default: return setApiResponse( HTTP.UNPROCESSABLE, out.reason, 'A pending migration deletes data. No down-migration brings it back — ' + 'apply it through CI or `npm run migrate`, not from here.', { files: out.files }, ); } }, ]` |
|
|
58
113
|
| GET | `/_admin/routes` | `async () => setApiResponse(HTTP.OK, 'ADMIN_ROUTES', 'Exposed routes', routesReport(app, this.name))` |
|
|
114
|
+
| DELETE | `/_admin/session` | `[ check, async (ctx) => { const cookie = readCookie(ctx); if (cookie) await deleteSession(store, cookie); return clearCookie(setApiResponse(HTTP.OK, 'SIGNED_OUT', 'Signed out'), ctx); }, ]` |
|
|
115
|
+
| GET | `/_admin/session` | `[ check, async (ctx) => { const found = await readSession(store, readCookie(ctx)); const claimable = (await operatorCount(store)) === 0; return setApiResponse(HTTP.OK, 'ADMIN_SESSION', 'Session', { state: found ? stateOf(found.session.stage) : 'signed-out', operator: found ? publicOperator(found.op) : null, claimable, stepUpFresh: found ? stepUpFresh(found.session) : false, }); }, ]` |
|
|
116
|
+
| POST | `/_admin/session/claim` | `[ check, validate(claimSchema), async (ctx) => { if (!constantTimeEqual(bearer(ctx), deps.rootToken)) { return setApiResponse( HTTP.UNAUTHORIZED, 'UNAUTHORIZED', 'Claiming needs the root admin token.', ); } const b = body(ctx); const email = normalizeEmail(str(b['email'])); if (!EMAIL_RE.test(email)) return setApiResponse(HTTP.UNPROCESSABLE, 'INVALID', 'Enter a valid email address.'); const problem = passwordProblem(b['password']); if (problem) return setApiResponse(HTTP.UNPROCESSABLE, 'WEAK_PASSWORD', problem); const op = await claimFirstOperator(store, { email, name: str(b['name']) || undefined, password: str(b['password']), }); if (!op) return setApiResponse( HTTP.CONFLICT, 'ALREADY_CLAIMED', 'This console already has operators. Ask one for an invite.', ); return startSession(deps, ctx, op, 'enroll'); }, ]` |
|
|
117
|
+
| GET | `/_admin/session/enrollment` | `[ check, async (ctx) => { const s = await sessionAt(deps, ctx, 'enroll'); if (!s) return WRONG_STAGE(); const secret = await enrollmentSecret(store, box, s.op); const issuer = `Admin · ${new URL(ctx.request.url).hostname}`; const res = setApiResponse(HTTP.OK, 'ENROLLMENT', 'Scan this with an authenticator app', { secret, uri: totpUri(issuer, s.op.email, secret), account: s.op.email, issuer, }); res.headers.set('cache-control', 'no-store'); return res; }, ]` |
|
|
118
|
+
| POST | `/_admin/session/enrollment` | `[ check, validate(enrollmentSchema), async (ctx) => { const s = await sessionAt(deps, ctx, 'enroll'); if (!s) return WRONG_STAGE(); const codes = await confirmEnrollment(store, box, s.op, str(body(ctx)['code'])); if (!codes) { const fresh = await findOperator(store, { id: s.op.id }); return fresh && lockMinutes(fresh) ? LOCKED(fresh) : BAD_CODE(); } // A new session id at the privilege change: a pending id that leaked // never becomes a signed-in one. await deleteSession(store, s.cookie); const op = (await findOperator(store, { id: s.op.id })) ?? s.op; return startSession(deps, ctx, op, 'active', { backupCodes: codes }); }, ]` |
|
|
119
|
+
| POST | `/_admin/session/link` | `[ check, validate(redeemLinkSchema), async (ctx) => { const b = body(ctx); const problem = passwordProblem(b['password']); if (problem) return setApiResponse(HTTP.UNPROCESSABLE, 'WEAK_PASSWORD', problem); const r = await redeemLink(store, str(b['token']), { password: str(b['password']), name: str(b['name']) || undefined, }); if ('error' in r) { return r.error === 'ALREADY_OPERATOR' ? setApiResponse( HTTP.CONFLICT, 'ALREADY_OPERATOR', 'That email is already an operator. Sign in instead.', ) : setApiResponse( HTTP.NOT_FOUND, 'INVALID_LINK', 'This link has expired or was already used. Ask for a new one.', ); } return startSession(deps, ctx, r.op, 'enroll'); }, ]` |
|
|
120
|
+
| POST | `/_admin/session/link/inspect` | `[ check, validate(inspectLinkSchema), async (ctx) => { const link = await findLink(store, str(body(ctx)['token'])); return link ? setApiResponse(HTTP.OK, 'LINK', 'Link', { kind: link.kind, email: link.email }) : setApiResponse( HTTP.NOT_FOUND, 'INVALID_LINK', 'This link has expired or was already used. Ask for a new one.', ); }, ]` |
|
|
121
|
+
| POST | `/_admin/session/login` | `[ check, validate(loginSchema), async (ctx) => { const b = body(ctx); const result = await checkPassword(store, str(b['email']), str(b['password'])); if (result.op && 'locked' in result && result.locked) return LOCKED(result.op); if (!result.ok || !result.op) return BAD_CREDENTIALS(); return startSession( deps, ctx, result.op, result.op.totpConfirmedAt ? 'password' : 'enroll', ); }, ]` |
|
|
122
|
+
| POST | `/_admin/session/step-up` | `[ check, validate(factorSchema), async (ctx) => { const s = await sessionAt(deps, ctx, 'active'); if (!s) return WRONG_STAGE(); const b = body(ctx); const r = await checkSecondFactor(store, box, s.op, { code: b['code'], backupCode: b['backupCode'], }); if (!r.ok) return BAD_CODE(); await markStepUp(store, s.session.idHash); return setApiResponse(HTTP.OK, 'STEPPED_UP', 'Confirmed for five minutes', { stepUpFresh: true, ...(r.via === 'backup' ? { backupCodesLeft: r.backupLeft } : {}), }); }, ]` |
|
|
123
|
+
| POST | `/_admin/session/verify` | `[ check, validate(factorSchema), async (ctx) => { const s = await sessionAt(deps, ctx, 'password'); if (!s) return WRONG_STAGE(); const b = body(ctx); const r = await checkSecondFactor(store, box, s.op, { code: b['code'], backupCode: b['backupCode'], }); if (!r.ok) { const fresh = await findOperator(store, { id: s.op.id }); return fresh && lockMinutes(fresh) ? LOCKED(fresh) : BAD_CODE(); } await deleteSession(store, s.cookie); await store.query( `UPDATE fonderie_admin_operators SET last_login_at = now() WHERE id = $1`, [s.op.id], ); return startSession( deps, ctx, s.op, 'active', r.via === 'backup' ? { backupCodesLeft: r.backupLeft } : {}, ); }, ]` |
|
package/brain/signatures.md
CHANGED
|
@@ -49,7 +49,7 @@ function applyModuleMigrations(store: IStoreAdapter, sets: readonly IMigrationSe
|
|
|
49
49
|
|
|
50
50
|
const applyMigrationsSchema: IRequestSchema
|
|
51
51
|
|
|
52
|
-
function requireAdminScope(bootstrap: string, store: IStoreAdapter | undefined, needed: AdminScope | "root"): Middleware
|
|
52
|
+
function requireAdminScope(bootstrap: string, store: IStoreAdapter | undefined, needed: AdminScope | "root", opts?: { stepUp?: boolean; operators?: boolean; }): Middleware
|
|
53
53
|
|
|
54
54
|
function scopeFor(method: string, path: string): AdminScope
|
|
55
55
|
|
|
@@ -75,6 +75,8 @@ interface IAdminOptions {
|
|
|
75
75
|
host?: string | string[];
|
|
76
76
|
store?: IStoreAdapter;
|
|
77
77
|
env?: string[];
|
|
78
|
+
operators?: boolean;
|
|
79
|
+
operatorKey?: string;
|
|
78
80
|
}
|
|
79
81
|
|
|
80
82
|
interface IAdminManifest {
|