@openparachute/hub 0.7.3-rc.2 → 0.7.3-rc.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.7.3-rc.2",
3
+ "version": "0.7.3-rc.4",
4
4
  "description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -733,6 +733,7 @@ import { findGrant, recordGrant } from "../grants.ts";
733
733
  import { findInviteByHash, issueInvite } from "../invites.ts";
734
734
  import { findTokenRowByJti, listActiveRevocations, recordTokenMint } from "../jwt-sign.ts";
735
735
  import { createUser, setUserVaults } from "../users.ts";
736
+ import { getVaultCapBytes, setVaultCap } from "../vault-caps.ts";
736
737
 
737
738
  const VAULT_ORIGIN = "http://127.0.0.1:19400";
738
739
  const AGENT_ORIGIN = "http://127.0.0.1:19410";
@@ -1026,11 +1027,19 @@ describe("DELETE /vaults/<name> — the identity cascade", () => {
1026
1027
  const pendingWork = issueInvite(db, { createdBy: alice.id, vaultName: "work" });
1027
1028
  const pendingDefault = issueInvite(db, { createdBy: alice.id, vaultName: "default" });
1028
1029
  const redeemedWork = issueInvite(db, { createdBy: alice.id, vaultName: "work" });
1029
- db.prepare("UPDATE invites SET used_at = ?, redeemed_user_id = ? WHERE token = ?").run(
1030
- new Date().toISOString(),
1031
- alice.id,
1032
- redeemedWork.invite.tokenHash,
1033
- );
1030
+ // Simulate a fully-redeemed single-use invite exactly as the redeem path
1031
+ // leaves it (v15): used_at stamped AND used_count bumped to max_uses, so
1032
+ // the cascade's `used_count < max_uses` exhaustion guard treats it as
1033
+ // terminal (untouched), same as the pre-v15 `used_at IS NULL` guard did.
1034
+ db.prepare(
1035
+ "UPDATE invites SET used_at = ?, used_count = 1, redeemed_user_id = ? WHERE token = ?",
1036
+ ).run(new Date().toISOString(), alice.id, redeemedWork.invite.tokenHash);
1037
+
1038
+ // 4b. vault_caps (v15): one row per vault. The work row is dropped by
1039
+ // the cascade; the default row stays (a re-created same-name vault
1040
+ // must not inherit a stale cap).
1041
+ setVaultCap(db, "work", 1024 * 1024 * 1024);
1042
+ setVaultCap(db, "default", 2 * 1024 * 1024 * 1024);
1034
1043
 
1035
1044
  // 5. Connections: one sourced on work (torn down), one on default (kept).
1036
1045
  putConnection(store, {
@@ -1092,6 +1101,7 @@ describe("DELETE /vaults/<name> — the identity cascade", () => {
1092
1101
  grants_dropped: number;
1093
1102
  user_vaults_removed: number;
1094
1103
  invites_invalidated: number;
1104
+ vault_cap_removed: boolean;
1095
1105
  connections_torn_down: number;
1096
1106
  orphaned_channels: string[];
1097
1107
  vault_removed: boolean;
@@ -1134,6 +1144,11 @@ describe("DELETE /vaults/<name> — the identity cascade", () => {
1134
1144
  expect(findInviteByHash(db, redeemedWork.invite.tokenHash)?.usedAt).not.toBeNull();
1135
1145
  expect(findInviteByHash(db, redeemedWork.invite.tokenHash)?.revokedAt).toBeNull();
1136
1146
 
1147
+ // 4b. vault_caps: the work cap row dropped; the default cap row stays.
1148
+ expect(out.cascade.vault_cap_removed).toBe(true);
1149
+ expect(getVaultCapBytes(db, "work")).toBeNull();
1150
+ expect(getVaultCapBytes(db, "default")).toBe(2 * 1024 * 1024 * 1024);
1151
+
1137
1152
  // 5. Connections: work connection torn down (trigger deregistered +
1138
1153
  // channel entry deleted + record removed); default connection kept.
1139
1154
  expect(out.cascade.connections_torn_down).toBe(1);
@@ -10,7 +10,9 @@ import {
10
10
  RefreshTokenInsertError,
11
11
  findRefreshToken,
12
12
  findTokenRowByJti,
13
+ linkRotation,
13
14
  listActiveRevocations,
15
+ liveFamilyRefreshRows,
14
16
  recordTokenMint,
15
17
  revokeTokenByJti,
16
18
  signAccessToken,
@@ -623,3 +625,80 @@ describe("token registry (hub#212 Phase 1)", () => {
623
625
  }
624
626
  });
625
627
  });
628
+
629
+ // hub#685 — rotation-grace primitives. The grace decision in
630
+ // `handleTokenRefresh` is built on these two helpers; unit-cover them
631
+ // directly so the security-load-bearing predecessor check has a tight test.
632
+ describe("rotation grace primitives (hub#685)", () => {
633
+ test("rotatedTo is null on a freshly minted row, set by linkRotation", async () => {
634
+ const { db, cleanup } = makeDb();
635
+ try {
636
+ rotateSigningKey(db);
637
+ const u = await createUser(db, "owner", "pw");
638
+ signRefreshToken(db, {
639
+ jti: "pred",
640
+ userId: u.id,
641
+ clientId: "parachute-hub",
642
+ scopes: ["vault:read"],
643
+ familyId: "fam-1",
644
+ });
645
+ expect(findTokenRowByJti(db, "pred")?.rotatedTo).toBeNull();
646
+
647
+ signRefreshToken(db, {
648
+ jti: "succ",
649
+ userId: u.id,
650
+ clientId: "parachute-hub",
651
+ scopes: ["vault:read"],
652
+ familyId: "fam-1",
653
+ });
654
+ linkRotation(db, "pred", "succ");
655
+ expect(findTokenRowByJti(db, "pred")?.rotatedTo).toBe("succ");
656
+ // Successor itself has not rotated yet.
657
+ expect(findTokenRowByJti(db, "succ")?.rotatedTo).toBeNull();
658
+ } finally {
659
+ cleanup();
660
+ }
661
+ });
662
+
663
+ test("liveFamilyRefreshRows returns only un-revoked, un-expired refresh rows in the family", async () => {
664
+ const { db, cleanup } = makeDb();
665
+ try {
666
+ rotateSigningKey(db);
667
+ const u = await createUser(db, "owner", "pw");
668
+ const t0 = new Date("2026-06-24T00:00:00Z");
669
+ for (const jti of ["a", "b", "c"]) {
670
+ signRefreshToken(db, {
671
+ jti,
672
+ userId: u.id,
673
+ clientId: "parachute-hub",
674
+ scopes: ["vault:read"],
675
+ familyId: "fam-x",
676
+ now: () => t0,
677
+ });
678
+ }
679
+ // A different family — must never leak in.
680
+ signRefreshToken(db, {
681
+ jti: "other",
682
+ userId: u.id,
683
+ clientId: "parachute-hub",
684
+ scopes: ["vault:read"],
685
+ familyId: "fam-y",
686
+ now: () => t0,
687
+ });
688
+ // Revoke one of the three.
689
+ revokeTokenByJti(db, "b", t0);
690
+
691
+ const live = liveFamilyRefreshRows(db, "fam-x", t0)
692
+ .map((r) => r.jti)
693
+ .sort();
694
+ expect(live).toEqual(["a", "c"]);
695
+
696
+ // Evaluated PAST the 30-day TTL: every row in the family is expired,
697
+ // so none count as a live tip — the grace path can't rotate into one.
698
+ const afterExpiry = new Date(t0.getTime() + REFRESH_TOKEN_TTL_MS + 1);
699
+ expect(liveFamilyRefreshRows(db, "fam-x", afterExpiry)).toEqual([]);
700
+ } finally {
701
+ cleanup();
702
+ }
703
+ });
704
+ });
@@ -14,7 +14,12 @@ import {
14
14
  openFirstClientAutoApproveWindow,
15
15
  setSetting,
16
16
  } from "../hub-settings.ts";
17
- import { findTokenRowByJti, validateAccessToken } from "../jwt-sign.ts";
17
+ import {
18
+ REFRESH_GRACE_MS,
19
+ findTokenRowByJti,
20
+ signRefreshToken,
21
+ validateAccessToken,
22
+ } from "../jwt-sign.ts";
18
23
  import {
19
24
  authorizationServerMetadata,
20
25
  buildServicesCatalog,
@@ -2262,6 +2267,7 @@ describe("handleToken — full OAuth dance", () => {
2262
2267
  refresh_token: initial.refresh_token,
2263
2268
  client_id: reg.client.clientId,
2264
2269
  });
2270
+ const rotateAt = new Date("2026-06-24T00:00:00Z");
2265
2271
  const refreshRes = await handleToken(
2266
2272
  db,
2267
2273
  new Request(`${ISSUER}/oauth/token`, {
@@ -2269,13 +2275,16 @@ describe("handleToken — full OAuth dance", () => {
2269
2275
  body: refreshForm,
2270
2276
  headers: { "content-type": "application/x-www-form-urlencoded" },
2271
2277
  }),
2272
- { issuer: ISSUER },
2278
+ { issuer: ISSUER, now: () => rotateAt },
2273
2279
  );
2274
2280
  expect(refreshRes.status).toBe(200);
2275
2281
  const rotated = (await refreshRes.json()) as { refresh_token: string };
2276
2282
  expect(rotated.refresh_token).not.toBe(initial.refresh_token);
2277
2283
 
2278
- // Old refresh token should now fail (revoked).
2284
+ // Old refresh token replayed PAST the one-generation grace window
2285
+ // should fail (revoked) — the immediate-predecessor grace (hub#685)
2286
+ // only tolerates a replay within REFRESH_GRACE_MS. Replay an hour
2287
+ // later: genuine zero-tolerance rejection.
2279
2288
  const replayRes = await handleToken(
2280
2289
  db,
2281
2290
  new Request(`${ISSUER}/oauth/token`, {
@@ -2283,7 +2292,7 @@ describe("handleToken — full OAuth dance", () => {
2283
2292
  body: refreshForm,
2284
2293
  headers: { "content-type": "application/x-www-form-urlencoded" },
2285
2294
  }),
2286
- { issuer: ISSUER },
2295
+ { issuer: ISSUER, now: () => new Date(rotateAt.getTime() + 60 * 60 * 1000) },
2287
2296
  );
2288
2297
  expect(replayRes.status).toBe(400);
2289
2298
  const err = (await replayRes.json()) as Record<string, unknown>;
@@ -4125,6 +4134,284 @@ describe("refresh-token rotation + /oauth/revoke (#73)", () => {
4125
4134
  }
4126
4135
  });
4127
4136
 
4137
+ // hub#685 — one-generation rotation grace window. A benign concurrent /
4138
+ // retried refresh of the *immediately-previous* (just-rotated) token within
4139
+ // REFRESH_GRACE_MS must NOT revoke the family (multi-tab SPA, bfcache/
4140
+ // stale-tab resume, network retry). Genuine theft — an older ancestor, or
4141
+ // any replay past the window — MUST still revoke the family.
4142
+ describe("rotation grace window (hub#685)", () => {
4143
+ function liveRefreshCount(
4144
+ db: Awaited<ReturnType<typeof makeDb>>["db"],
4145
+ familyId: string,
4146
+ ): number {
4147
+ const r = db
4148
+ .query<{ n: number }, [string]>(
4149
+ "SELECT COUNT(*) AS n FROM tokens WHERE family_id = ? AND revoked_at IS NULL AND refresh_token_hash IS NOT NULL",
4150
+ )
4151
+ .get(familyId);
4152
+ return r?.n ?? -1;
4153
+ }
4154
+
4155
+ async function refreshAt(
4156
+ db: Awaited<ReturnType<typeof makeDb>>["db"],
4157
+ clientId: string,
4158
+ refreshToken: string,
4159
+ at: Date,
4160
+ ): Promise<Response> {
4161
+ return handleToken(
4162
+ db,
4163
+ tokenRequest(
4164
+ new URLSearchParams({
4165
+ grant_type: "refresh_token",
4166
+ refresh_token: refreshToken,
4167
+ client_id: clientId,
4168
+ }),
4169
+ ),
4170
+ { issuer: ISSUER, loadServicesManifest: fixtureLoadServicesManifest, now: () => at },
4171
+ );
4172
+ }
4173
+
4174
+ test("benign concurrent refresh of the immediate predecessor within the window succeeds, no family revocation", async () => {
4175
+ const { db, cleanup } = await makeDb();
4176
+ try {
4177
+ const user = await createUser(db, "owner", "pw");
4178
+ const session = createSession(db, { userId: user.id });
4179
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
4180
+ const initial = await mintInitialPair(db, reg.client.clientId, user.id, session.id);
4181
+ const family = familyIdFor(db, initial.refresh_token);
4182
+
4183
+ const t0 = new Date("2026-06-24T00:00:00Z");
4184
+ // First (legitimate) rotation at t0 — `initial` becomes the immediate
4185
+ // predecessor; rotated1 is the single live tip.
4186
+ const r1 = await refreshAt(db, reg.client.clientId, initial.refresh_token, t0);
4187
+ expect(r1.status).toBe(200);
4188
+ const rotated1 = (await r1.json()) as { refresh_token: string };
4189
+ expect(liveRefreshCount(db, family)).toBe(1);
4190
+
4191
+ // A moment later (5s, well within REFRESH_GRACE_MS) a stale tab /
4192
+ // retry replays `initial`. Benign: succeeds, and converges the client
4193
+ // onto the lineage rather than revoking the family.
4194
+ const replay = await refreshAt(
4195
+ db,
4196
+ reg.client.clientId,
4197
+ initial.refresh_token,
4198
+ new Date(t0.getTime() + 5_000),
4199
+ );
4200
+ expect(replay.status).toBe(200);
4201
+ const replayed = (await replay.json()) as { refresh_token: string; access_token: string };
4202
+ expect(replayed.refresh_token).toBeTruthy();
4203
+ expect(replayed.access_token).toBeTruthy();
4204
+
4205
+ // No family-wide revocation: exactly one live refresh row remains
4206
+ // (the grace path rotated the tip → its successor is the new tip).
4207
+ expect(liveRefreshCount(db, family)).toBe(1);
4208
+
4209
+ // The token handed back is usable for a normal subsequent refresh.
4210
+ const next = await refreshAt(
4211
+ db,
4212
+ reg.client.clientId,
4213
+ replayed.refresh_token,
4214
+ new Date(t0.getTime() + 10_000),
4215
+ );
4216
+ expect(next.status).toBe(200);
4217
+ } finally {
4218
+ cleanup();
4219
+ }
4220
+ });
4221
+
4222
+ test("genuine reuse of an OLDER ancestor (two generations back) revokes the family even within the window", async () => {
4223
+ const { db, cleanup } = await makeDb();
4224
+ try {
4225
+ const user = await createUser(db, "owner", "pw");
4226
+ const session = createSession(db, { userId: user.id });
4227
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
4228
+ const initial = await mintInitialPair(db, reg.client.clientId, user.id, session.id);
4229
+ const family = familyIdFor(db, initial.refresh_token);
4230
+
4231
+ const t0 = new Date("2026-06-24T00:00:00Z");
4232
+ // Two legitimate rotations: initial → rotated1 → rotated2. `initial`
4233
+ // is now an ancestor (its successor rotated1 is itself revoked).
4234
+ const r1 = await refreshAt(db, reg.client.clientId, initial.refresh_token, t0);
4235
+ const rotated1 = (await r1.json()) as { refresh_token: string };
4236
+ const r2 = await refreshAt(
4237
+ db,
4238
+ reg.client.clientId,
4239
+ rotated1.refresh_token,
4240
+ new Date(t0.getTime() + 1_000),
4241
+ );
4242
+ expect(r2.status).toBe(200);
4243
+
4244
+ // Replay the ANCESTOR `initial` still within the window. Condition (b)
4245
+ // fails — its successor (rotated1) is not the live tip — so this is
4246
+ // treated as theft: family revoked.
4247
+ const replay = await refreshAt(
4248
+ db,
4249
+ reg.client.clientId,
4250
+ initial.refresh_token,
4251
+ new Date(t0.getTime() + 2_000),
4252
+ );
4253
+ expect(replay.status).toBe(400);
4254
+ expect(((await replay.json()) as Record<string, unknown>).error).toBe("invalid_grant");
4255
+ expect(liveRefreshCount(db, family)).toBe(0);
4256
+ } finally {
4257
+ cleanup();
4258
+ }
4259
+ });
4260
+
4261
+ test("replay of the immediate predecessor AFTER the window revokes the family", async () => {
4262
+ const { db, cleanup } = await makeDb();
4263
+ try {
4264
+ const user = await createUser(db, "owner", "pw");
4265
+ const session = createSession(db, { userId: user.id });
4266
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
4267
+ const initial = await mintInitialPair(db, reg.client.clientId, user.id, session.id);
4268
+ const family = familyIdFor(db, initial.refresh_token);
4269
+
4270
+ const t0 = new Date("2026-06-24T00:00:00Z");
4271
+ const r1 = await refreshAt(db, reg.client.clientId, initial.refresh_token, t0);
4272
+ expect(r1.status).toBe(200);
4273
+
4274
+ // Replay the immediate predecessor 1ms PAST the window. Condition (a)
4275
+ // fails: zero-tolerance theft handling applies.
4276
+ const replay = await refreshAt(
4277
+ db,
4278
+ reg.client.clientId,
4279
+ initial.refresh_token,
4280
+ new Date(t0.getTime() + REFRESH_GRACE_MS + 1),
4281
+ );
4282
+ expect(replay.status).toBe(400);
4283
+ expect(((await replay.json()) as Record<string, unknown>).error).toBe("invalid_grant");
4284
+ expect(liveRefreshCount(db, family)).toBe(0);
4285
+ } finally {
4286
+ cleanup();
4287
+ }
4288
+ });
4289
+
4290
+ test("window boundary: exactly at the edge succeeds, one ms past fails", async () => {
4291
+ const { db, cleanup } = await makeDb();
4292
+ try {
4293
+ const user = await createUser(db, "owner", "pw");
4294
+ const session = createSession(db, { userId: user.id });
4295
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
4296
+
4297
+ // Just inside (=== REFRESH_GRACE_MS): benign success, family intact.
4298
+ const aPair = await mintInitialPair(db, reg.client.clientId, user.id, session.id);
4299
+ const aFamily = familyIdFor(db, aPair.refresh_token);
4300
+ const a0 = new Date("2026-06-24T00:00:00Z");
4301
+ await refreshAt(db, reg.client.clientId, aPair.refresh_token, a0);
4302
+ const inEdge = await refreshAt(
4303
+ db,
4304
+ reg.client.clientId,
4305
+ aPair.refresh_token,
4306
+ new Date(a0.getTime() + REFRESH_GRACE_MS),
4307
+ );
4308
+ expect(inEdge.status).toBe(200);
4309
+ expect(liveRefreshCount(db, aFamily)).toBe(1);
4310
+
4311
+ // One ms past the edge: theft handling, family revoked.
4312
+ const bPair = await mintInitialPair(db, reg.client.clientId, user.id, session.id);
4313
+ const bFamily = familyIdFor(db, bPair.refresh_token);
4314
+ const b0 = new Date("2026-06-24T01:00:00Z");
4315
+ await refreshAt(db, reg.client.clientId, bPair.refresh_token, b0);
4316
+ const outEdge = await refreshAt(
4317
+ db,
4318
+ reg.client.clientId,
4319
+ bPair.refresh_token,
4320
+ new Date(b0.getTime() + REFRESH_GRACE_MS + 1),
4321
+ );
4322
+ expect(outEdge.status).toBe(400);
4323
+ expect(liveRefreshCount(db, bFamily)).toBe(0);
4324
+ } finally {
4325
+ cleanup();
4326
+ }
4327
+ });
4328
+
4329
+ test("repeated benign replays of the same predecessor each converge on a live tip (idempotent-equivalent)", async () => {
4330
+ const { db, cleanup } = await makeDb();
4331
+ try {
4332
+ const user = await createUser(db, "owner", "pw");
4333
+ const session = createSession(db, { userId: user.id });
4334
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
4335
+ const initial = await mintInitialPair(db, reg.client.clientId, user.id, session.id);
4336
+ const family = familyIdFor(db, initial.refresh_token);
4337
+
4338
+ const t0 = new Date("2026-06-24T00:00:00Z");
4339
+ await refreshAt(db, reg.client.clientId, initial.refresh_token, t0);
4340
+
4341
+ // First benign replay of `initial`: succeeds, rotates the tip.
4342
+ const replay1 = await refreshAt(
4343
+ db,
4344
+ reg.client.clientId,
4345
+ initial.refresh_token,
4346
+ new Date(t0.getTime() + 1_000),
4347
+ );
4348
+ expect(replay1.status).toBe(200);
4349
+ expect(liveRefreshCount(db, family)).toBe(1);
4350
+
4351
+ // A SECOND replay of the SAME predecessor `initial`. Its successor is
4352
+ // now revoked (the first replay rotated the tip), so `initial` is no
4353
+ // longer the direct predecessor of the single live tip → theft. The
4354
+ // grace window protects exactly ONE generation, not unbounded replay.
4355
+ const replay2 = await refreshAt(
4356
+ db,
4357
+ reg.client.clientId,
4358
+ initial.refresh_token,
4359
+ new Date(t0.getTime() + 2_000),
4360
+ );
4361
+ expect(replay2.status).toBe(400);
4362
+ expect(liveRefreshCount(db, family)).toBe(0);
4363
+ } finally {
4364
+ cleanup();
4365
+ }
4366
+ });
4367
+
4368
+ test("already-forked family (multiple live tips) does NOT take the grace path — family revoked", async () => {
4369
+ const { db, cleanup } = await makeDb();
4370
+ try {
4371
+ const user = await createUser(db, "owner", "pw");
4372
+ const session = createSession(db, { userId: user.id });
4373
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
4374
+ const initial = await mintInitialPair(db, reg.client.clientId, user.id, session.id);
4375
+ const family = familyIdFor(db, initial.refresh_token);
4376
+
4377
+ const t0 = new Date("2026-06-24T00:00:00Z");
4378
+ // One legitimate rotation → `initial` is the immediate predecessor of
4379
+ // the single live tip (live count == 1, the benign precondition).
4380
+ const r1 = await refreshAt(db, reg.client.clientId, initial.refresh_token, t0);
4381
+ expect(r1.status).toBe(200);
4382
+ expect(liveRefreshCount(db, family)).toBe(1);
4383
+
4384
+ // Inject a SECOND live refresh row into the same family — simulating a
4385
+ // family that has already forked into multiple live lineages (an
4386
+ // already-compromised state). Now live count != 1, so `tip` is null.
4387
+ signRefreshToken(db, {
4388
+ jti: "injected-fork-jti",
4389
+ userId: user.id,
4390
+ clientId: reg.client.clientId,
4391
+ scopes: ["vault:default:read"],
4392
+ familyId: family,
4393
+ now: () => t0,
4394
+ });
4395
+ expect(liveRefreshCount(db, family)).toBe(2);
4396
+
4397
+ // Replay the immediate predecessor WITHIN the window. Even though (a)
4398
+ // holds and `initial.rotatedTo` is set, the single-live-tip check
4399
+ // fails (2 live rows) → no grace → zero-tolerance family revocation.
4400
+ const replay = await refreshAt(
4401
+ db,
4402
+ reg.client.clientId,
4403
+ initial.refresh_token,
4404
+ new Date(t0.getTime() + 1_000),
4405
+ );
4406
+ expect(replay.status).toBe(400);
4407
+ expect(((await replay.json()) as Record<string, unknown>).error).toBe("invalid_grant");
4408
+ expect(liveRefreshCount(db, family)).toBe(0);
4409
+ } finally {
4410
+ cleanup();
4411
+ }
4412
+ });
4413
+ });
4414
+
4128
4415
  test("/oauth/revoke refresh_token: revokes the row, second use rejected", async () => {
4129
4416
  const { db, cleanup } = await makeDb();
4130
4417
  try {