@oimlsmart/platform-server 0.2.5 → 0.2.7

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.
@@ -0,0 +1,61 @@
1
+ -- Migration 0025 — the SSO wave-C token surface (TODO.identity-sso: the
2
+ -- refresh-token grant with rotation + revocation): the OP's refresh
3
+ -- tokens. A refresh token is the offline half of the remembered consent
4
+ -- (the Relying Party keeps the authorization while the holder is away),
5
+ -- so the row carries the granting code's full provenance: the canonical
6
+ -- scope spelling, the context_org, the amr, and the ORIGINAL
7
+ -- authentication instant (auth_time never advances on a refresh — the
8
+ -- refreshed ID token proves the original authentication, never a new
9
+ -- one).
10
+ --
11
+ -- The doctrines:
12
+ -- - ONE-TIME, atomically consumed (the oidc_codes / email_change_tokens
13
+ -- posture): the exchange's UPDATE … WHERE consumed_at IS NULL flips
14
+ -- exactly once — a replay loses the race;
15
+ -- - the FAMILY: every rotation descends from the first mint and carries
16
+ -- its family_id. A presented CONSUMED token is the theft signal
17
+ -- (RFC 6819 §5.2.2.3): the whole family dies (DELETE WHERE
18
+ -- family_id), so the attacker's copy and the holder's legitimate
19
+ -- chain both end;
20
+ -- - the consumed row STAYS (the reuse detector reads it) until the
21
+ -- family's end: revocation (RFC 7009, client-bound — a client revokes
22
+ -- only its own, and revoking one refresh token revokes the family),
23
+ -- the consent's revocation, the deactivation sweep, and the account
24
+ -- erasure remove the rows outright (the personal_access_tokens
25
+ -- doctrine);
26
+ -- - expiry is per row (the consumer passes ttlMs at each mint; a
27
+ -- rotation slides the window forward on the NEW row). An expired
28
+ -- present is consumed anyway — never a second chance.
29
+ -- schema.sql carries the same end state for fresh databases —
30
+ -- test/migrations.test.ts pins the UNION of every migration to
31
+ -- schema.sql's CREATE set.
32
+
33
+ CREATE TABLE IF NOT EXISTS oidc_refresh_tokens (
34
+ token TEXT PRIMARY KEY,
35
+ user_id TEXT NOT NULL REFERENCES users(id),
36
+ client_id TEXT NOT NULL,
37
+ -- The granted scope set, the canonical space-joined spelling
38
+ -- (normalizeOidcScopeSet) — a refresh never widens it (the route's
39
+ -- math), the row is the grant's record.
40
+ scope TEXT NOT NULL,
41
+ -- The granting code's context — a refreshed access token answers the
42
+ -- SAME claims the original carried.
43
+ context_org TEXT,
44
+ -- The authorizing authentication's amr provenance (a JSON array; NULL =
45
+ -- none recorded).
46
+ amr TEXT,
47
+ -- The ORIGINAL authentication instant (verbatim from the consenting
48
+ -- session; NULL = none recorded) — carried into every refreshed ID
49
+ -- token's auth_time.
50
+ auth_time TEXT,
51
+ -- The rotation lineage: the first mint's generated id, inherited by
52
+ -- every rotation. Reuse of a consumed row kills the family.
53
+ family_id TEXT NOT NULL,
54
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
55
+ expires_at TEXT NOT NULL,
56
+ consumed_at TEXT
57
+ );
58
+ -- The deactivation/erasure sweeps + the account console's per-app read.
59
+ CREATE INDEX IF NOT EXISTS idx_oidc_refresh_tokens_user ON oidc_refresh_tokens (user_id);
60
+ -- The reuse-kill + the RFC 7009 family revocation.
61
+ CREATE INDEX IF NOT EXISTS idx_oidc_refresh_tokens_family ON oidc_refresh_tokens (family_id);
@@ -0,0 +1,15 @@
1
+ -- The per-store journal high-water (the ServerStore seam's
2
+ -- latestChangeSeqFor(store), the 2026-09-07 performance audit's G1):
3
+ -- the seam's DOC-ONLY contract since 0.2.2 ("the (store, seq) walk —
4
+ -- the index rides the same commit") lands. The read answers MAX(seq)
5
+ -- over one store's slice of the ONE global journal — the seq stays
6
+ -- global and monotone, this is a projection, never a second sequence.
7
+ -- Without the index the projection scans the journal's rowid walk and
8
+ -- filters; with it the read is a single indexed probe, which is the
9
+ -- contract's whole point (per-store conditional revalidation / SSE
10
+ -- resume probes paying one read, never a payload fetch).
11
+ --
12
+ -- CREATE INDEX IF NOT EXISTS is the idempotent guard (the migration
13
+ -- contract's expand-only discipline; a consumer that already carries
14
+ -- the index converges). schema.sql's mirror lands in the same commit.
15
+ CREATE INDEX IF NOT EXISTS idx_entity_changes_store_seq ON entity_changes (store, seq);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oimlsmart/platform-server",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
4
4
  "description": "The OIML SMART platform server kernel: the store seam (ServerStore + the D1 and SQLite implementations), the canonical D1 migration set both deployments apply, the instance profile, the mailer, the RBAC map, the OIDC/OAuth client cones, and the shared role/permission vocabulary. Consumed by the smart monorepo (browser/) and the identity service.",
5
5
  "type": "module",
6
6
  "repository": {
package/src/store/d1.ts CHANGED
@@ -39,6 +39,7 @@ import {
39
39
  type AuthUserPayload,
40
40
  type CompleteEmailChangeResult,
41
41
  type CompleteEnrollmentResult,
42
+ type ConsumeOidcRefreshTokenResult,
42
43
  type EmailChangeToken,
43
44
  type EnrollmentToken,
44
45
  type EntityChange,
@@ -64,6 +65,7 @@ import {
64
65
  type OidcCode,
65
66
  type OidcConsentGrant,
66
67
  type OidcKeyRow,
68
+ type OidcRefreshToken,
67
69
  consentGrantCovers,
68
70
  normalizeOidcScopeSet,
69
71
  type OpAccountErasure,
@@ -227,6 +229,7 @@ interface EnsureMemos {
227
229
  oidcColumns: Promise<void> | null
228
230
  personalAccessTokenSupport: Promise<void> | null
229
231
  consentGrantSupport: Promise<void> | null
232
+ oidcRefreshTokenSupport: Promise<void> | null
230
233
  accountEmailSupport: Promise<void> | null
231
234
  notifyDeliverySupport: Promise<void> | null
232
235
  }
@@ -243,6 +246,7 @@ function ensured(binding: D1Database, slot: keyof EnsureMemos, run: () => Promis
243
246
  orgRegistrySupport: null, holderAttributionSupport: null,
244
247
  instrumentRegistrationSupport: null, oidcColumns: null,
245
248
  personalAccessTokenSupport: null, consentGrantSupport: null,
249
+ oidcRefreshTokenSupport: null,
246
250
  accountEmailSupport: null, notifyDeliverySupport: null,
247
251
  }
248
252
  ensureMemosByBinding.set(binding, memos)
@@ -706,6 +710,33 @@ export class D1ServerStore implements ServerStore {
706
710
  })
707
711
  }
708
712
 
713
+ // TODO.identity-sso (the wave-C token surface): the oidc_refresh_tokens
714
+ // table arrives with migration 0025 — a dev D1 migrated from before it
715
+ // lacks the table, so the refresh methods (and the deactivation sweep)
716
+ // ensure it defensively (the ensureConsentGrantSupport posture,
717
+ // memoized per (binding, chain) at module scope).
718
+ private ensureOidcRefreshTokenSupport(): Promise<void> {
719
+ return ensured(this.binding, 'oidcRefreshTokenSupport', async () => {
720
+ await this.db.prepare(
721
+ `CREATE TABLE IF NOT EXISTS oidc_refresh_tokens (
722
+ token TEXT PRIMARY KEY,
723
+ user_id TEXT NOT NULL REFERENCES users(id),
724
+ client_id TEXT NOT NULL,
725
+ scope TEXT NOT NULL,
726
+ context_org TEXT,
727
+ amr TEXT,
728
+ auth_time TEXT,
729
+ family_id TEXT NOT NULL,
730
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
731
+ expires_at TEXT NOT NULL,
732
+ consumed_at TEXT
733
+ )`,
734
+ ).run()
735
+ await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_oidc_refresh_tokens_user ON oidc_refresh_tokens (user_id)').run()
736
+ await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_oidc_refresh_tokens_family ON oidc_refresh_tokens (family_id)').run()
737
+ })
738
+ }
739
+
709
740
  // TODO.identity-features/01 (multiple emails per account): the
710
741
  // account_emails table + the email_change_tokens.kind column arrive
711
742
  // with migration 0022 — a dev D1 migrated from before it lacks both,
@@ -1373,7 +1404,7 @@ export class D1ServerStore implements ServerStore {
1373
1404
  async getOidcAccessToken(token: string): Promise<OidcAccessToken | null> {
1374
1405
  await this.ensureMembershipSupport()
1375
1406
  const row = await this.stmt(
1376
- "SELECT * FROM oidc_access_tokens WHERE token = ? AND expires_at > datetime('now')", token,
1407
+ "SELECT * FROM oidc_access_tokens WHERE token = ? AND datetime(expires_at) > datetime('now')", token,
1377
1408
  ).first<Record<string, unknown>>()
1378
1409
  if (!row) return null
1379
1410
  return {
@@ -1387,6 +1418,124 @@ export class D1ServerStore implements ServerStore {
1387
1418
  }
1388
1419
  }
1389
1420
 
1421
+ /** The account console's per-app read + the governance view's per-user
1422
+ * slice: the account's LIVE access tokens, newest first — created_at
1423
+ * is second-resolution, the rowid breaks the tie. */
1424
+ async listOidcAccessTokens(userId: string): Promise<OidcAccessToken[]> {
1425
+ await this.ensureMembershipSupport()
1426
+ const res = await this.stmt(
1427
+ "SELECT * FROM oidc_access_tokens WHERE user_id = ? AND datetime(expires_at) > datetime('now') ORDER BY created_at DESC, rowid DESC", userId,
1428
+ ).all<Record<string, unknown>>()
1429
+ return res.results.map(row => ({
1430
+ token: row.token as string,
1431
+ userId: row.user_id as string,
1432
+ clientId: row.client_id as string,
1433
+ scope: row.scope as string,
1434
+ contextOrg: (row.context_org as string | null) ?? null,
1435
+ amr: parseRoles((row.amr as string | null) ?? null) ?? null,
1436
+ expiresAt: row.expires_at as string,
1437
+ }))
1438
+ }
1439
+
1440
+ /** The RFC 7009 access-token revocation: the row goes, client-bound. */
1441
+ async deleteOidcAccessToken(token: string, clientId: string): Promise<boolean> {
1442
+ await this.ensureMembershipSupport()
1443
+ const res = await this.stmt(
1444
+ 'DELETE FROM oidc_access_tokens WHERE token = ? AND client_id = ?', token, clientId,
1445
+ ).run()
1446
+ return (res.meta.changes ?? 0) > 0
1447
+ }
1448
+
1449
+ private static toRefreshToken(row: Record<string, unknown>): OidcRefreshToken {
1450
+ return {
1451
+ token: row.token as string,
1452
+ userId: row.user_id as string,
1453
+ clientId: row.client_id as string,
1454
+ scope: row.scope as string,
1455
+ contextOrg: (row.context_org as string | null) ?? null,
1456
+ amr: parseRoles((row.amr as string | null) ?? null) ?? null,
1457
+ authTime: (row.auth_time as string | null) ?? null,
1458
+ familyId: row.family_id as string,
1459
+ createdAt: row.created_at as string,
1460
+ expiresAt: row.expires_at as string,
1461
+ consumedAt: (row.consumed_at as string | null) ?? null,
1462
+ }
1463
+ }
1464
+
1465
+ async createOidcRefreshToken(input: {
1466
+ token: string
1467
+ userId: string
1468
+ clientId: string
1469
+ scope: string
1470
+ contextOrg?: string | null
1471
+ amr?: string[] | null
1472
+ authTime?: string | null
1473
+ familyId: string
1474
+ ttlMs: number
1475
+ }): Promise<OidcRefreshToken> {
1476
+ await this.ensureMembershipSupport()
1477
+ await this.ensureOidcRefreshTokenSupport()
1478
+ const expiresAt = new Date(Date.now() + input.ttlMs).toISOString()
1479
+ await this.stmt(
1480
+ `INSERT INTO oidc_refresh_tokens (token, user_id, client_id, scope, context_org, amr, auth_time, family_id, expires_at)
1481
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1482
+ input.token, input.userId, input.clientId, input.scope, input.contextOrg ?? null,
1483
+ input.amr?.length ? JSON.stringify(input.amr) : null, input.authTime ?? null, input.familyId, expiresAt,
1484
+ ).run()
1485
+ const row = await this.stmt('SELECT * FROM oidc_refresh_tokens WHERE token = ?', input.token).first<Record<string, unknown>>()
1486
+ return D1ServerStore.toRefreshToken(row as Record<string, unknown>)
1487
+ }
1488
+
1489
+ /** The refresh exchange's consume: the UPDATE flips consumed_at exactly
1490
+ * once — a replay loses the race, reads the consumed row back, and the
1491
+ * WHOLE FAMILY dies (the theft signal, RFC 6819 §5.2.2.3; a concurrent
1492
+ * double-present lands the same verdict — fail toward invalidation).
1493
+ * An expired live row is consumed anyway (never a second chance) and
1494
+ * answers 'invalid'. */
1495
+ async consumeOidcRefreshToken(token: string): Promise<ConsumeOidcRefreshTokenResult> {
1496
+ await this.ensureMembershipSupport()
1497
+ await this.ensureOidcRefreshTokenSupport()
1498
+ const res = await this.stmt(
1499
+ "UPDATE oidc_refresh_tokens SET consumed_at = datetime('now') WHERE token = ? AND consumed_at IS NULL", token,
1500
+ ).run()
1501
+ if ((res.meta.changes ?? 0) > 0) {
1502
+ const row = await this.stmt('SELECT * FROM oidc_refresh_tokens WHERE token = ?', token).first<Record<string, unknown>>()
1503
+ if (!row) return { kind: 'invalid' }
1504
+ if (new Date(row.expires_at as string).getTime() <= Date.now()) return { kind: 'invalid' }
1505
+ return { kind: 'ok', token: D1ServerStore.toRefreshToken(row) }
1506
+ }
1507
+ const row = await this.stmt('SELECT * FROM oidc_refresh_tokens WHERE token = ?', token).first<Record<string, unknown>>()
1508
+ if (!row) return { kind: 'invalid' }
1509
+ // The reuse signal: the consumed row's family dies outright.
1510
+ const familyId = row.family_id as string
1511
+ await this.stmt('DELETE FROM oidc_refresh_tokens WHERE family_id = ?', familyId).run()
1512
+ return { kind: 'reuse', familyId, userId: row.user_id as string, clientId: row.client_id as string }
1513
+ }
1514
+
1515
+ /** The RFC 7009 refresh revocation, client-bound: the presented token's
1516
+ * family goes (never another client's rows). */
1517
+ async revokeOidcRefreshToken(token: string, clientId: string): Promise<boolean> {
1518
+ await this.ensureMembershipSupport()
1519
+ await this.ensureOidcRefreshTokenSupport()
1520
+ const row = await this.stmt(
1521
+ 'SELECT family_id FROM oidc_refresh_tokens WHERE token = ? AND client_id = ?', token, clientId,
1522
+ ).first<{ family_id: string }>()
1523
+ if (!row) return false
1524
+ await this.stmt('DELETE FROM oidc_refresh_tokens WHERE family_id = ?', row.family_id).run()
1525
+ return true
1526
+ }
1527
+
1528
+ /** The consent revocation's companion: the (account, client) pair's
1529
+ * refresh rows all go. */
1530
+ async deleteOidcRefreshTokensForUserClient(userId: string, clientId: string): Promise<number> {
1531
+ await this.ensureMembershipSupport()
1532
+ await this.ensureOidcRefreshTokenSupport()
1533
+ const res = await this.stmt(
1534
+ 'DELETE FROM oidc_refresh_tokens WHERE user_id = ? AND client_id = ?', userId, clientId,
1535
+ ).run()
1536
+ return res.meta.changes ?? 0
1537
+ }
1538
+
1390
1539
  async listOidcKeys(): Promise<OidcKeyRow[]> {
1391
1540
  const res = await this.stmt('SELECT * FROM oidc_keys ORDER BY created_at, kid').all<Record<string, unknown>>()
1392
1541
  return res.results.map(row => ({
@@ -1842,16 +1991,21 @@ export class D1ServerStore implements ServerStore {
1842
1991
  }
1843
1992
 
1844
1993
  /** The deactivation's revocation half: every live session, every issued
1845
- * access token, every unconsumed code and pending authorization goes.
1846
- * The user row STAYS (the history). */
1847
- async revokeOpUserCredentials(userId: string): Promise<{ sessions: number; accessTokens: number; codes: number; authorizations: number }> {
1994
+ * access token, every refresh token (migration 0025 the ensure runs
1995
+ * first: a dev D1 from before it lacks the table), every unconsumed
1996
+ * code and pending authorization goes. The user row STAYS (the
1997
+ * history). */
1998
+ async revokeOpUserCredentials(userId: string): Promise<{ sessions: number; accessTokens: number; refreshTokens: number; codes: number; authorizations: number }> {
1999
+ await this.ensureOidcRefreshTokenSupport()
1848
2000
  const sessions = await this.stmt('DELETE FROM sessions WHERE user_id = ?', userId).run()
1849
2001
  const accessTokens = await this.stmt('DELETE FROM oidc_access_tokens WHERE user_id = ?', userId).run()
2002
+ const refreshTokens = await this.stmt('DELETE FROM oidc_refresh_tokens WHERE user_id = ?', userId).run()
1850
2003
  const codes = await this.stmt('DELETE FROM oidc_codes WHERE user_id = ? AND consumed_at IS NULL', userId).run()
1851
2004
  const authorizations = await this.stmt('DELETE FROM oidc_authorizations WHERE user_id = ? AND decision IS NULL', userId).run()
1852
2005
  return {
1853
2006
  sessions: sessions.meta.changes ?? 0,
1854
2007
  accessTokens: accessTokens.meta.changes ?? 0,
2008
+ refreshTokens: refreshTokens.meta.changes ?? 0,
1855
2009
  codes: codes.meta.changes ?? 0,
1856
2010
  authorizations: authorizations.meta.changes ?? 0,
1857
2011
  }
@@ -3326,6 +3480,16 @@ export class D1ServerStore implements ServerStore {
3326
3480
  return row?.seq ?? 0
3327
3481
  }
3328
3482
 
3483
+ async latestChangeSeqFor(store: string): Promise<number> {
3484
+ // One indexed probe over idx_entity_changes_store_seq (migration
3485
+ // 0026) — the per-store projection of the one journal; 0 when the
3486
+ // store carries no rows (MAX answers NULL on the empty set).
3487
+ const row = await this.stmt(
3488
+ 'SELECT MAX(seq) AS seq FROM entity_changes WHERE store = ?', store,
3489
+ ).first<{ seq: number | null }>()
3490
+ return row?.seq ?? 0
3491
+ }
3492
+
3329
3493
  // ── the platform event store (TODO.notify/01) ─────────────────────
3330
3494
  // The event rows port directly (D1 is SQLite) — the same statements
3331
3495
  // as sqlite/events.ts's sync half.
@@ -74,3 +74,14 @@ export function latestChangeSeq(): number {
74
74
  const row = getDb().prepare('SELECT MAX(seq) AS seq FROM entity_changes').get() as { seq: number | null }
75
75
  return row.seq ?? 0
76
76
  }
77
+
78
+ /** The per-store projection of the one journal (the seam's contract):
79
+ * MAX(seq) over the store's own slice, 0 on a store with no writes.
80
+ * Walks idx_entity_changes_store_seq — one indexed probe, never a
81
+ * journal scan. */
82
+ export function latestChangeSeqFor(store: string): number {
83
+ const row = getDb()
84
+ .prepare('SELECT MAX(seq) AS seq FROM entity_changes WHERE store = ?')
85
+ .get(store) as { seq: number | null }
86
+ return row.seq ?? 0
87
+ }
@@ -299,15 +299,18 @@ export function deleteOpClientRoles(userId: string, clientId: string): boolean {
299
299
  }
300
300
 
301
301
  /** The deactivation's revocation half: every live session, every issued
302
- * access token, every unconsumed code and pending authorization goes.
303
- * The user row STAYS (the history). */
304
- export function revokeOpUserCredentials(userId: string): { sessions: number; accessTokens: number; codes: number; authorizations: number } {
302
+ * access token, every refresh token (migration 0025 consumed or not;
303
+ * the reuse detector has no more verdicts to give), every unconsumed
304
+ * code and pending authorization goes. The user row STAYS (the
305
+ * history). */
306
+ export function revokeOpUserCredentials(userId: string): { sessions: number; accessTokens: number; refreshTokens: number; codes: number; authorizations: number } {
305
307
  const db = getDb()
306
308
  const sessions = db.prepare('DELETE FROM sessions WHERE user_id = ?').run(userId).changes
307
309
  const accessTokens = db.prepare('DELETE FROM oidc_access_tokens WHERE user_id = ?').run(userId).changes
310
+ const refreshTokens = db.prepare('DELETE FROM oidc_refresh_tokens WHERE user_id = ?').run(userId).changes
308
311
  const codes = db.prepare('DELETE FROM oidc_codes WHERE user_id = ? AND consumed_at IS NULL').run(userId).changes
309
312
  const authorizations = db.prepare('DELETE FROM oidc_authorizations WHERE user_id = ? AND decision IS NULL').run(userId).changes
310
- return { sessions, accessTokens, codes, authorizations }
313
+ return { sessions, accessTokens, refreshTokens, codes, authorizations }
311
314
  }
312
315
 
313
316
  /** The registry's edit act (name/email). The email UNIQUE conflict
@@ -351,6 +354,7 @@ export function updateOpAccount(id: string, input: { name?: string; email?: stri
351
354
  export function eraseOpAccount(userId: string): {
352
355
  sessions: number
353
356
  accessTokens: number
357
+ refreshTokens: number
354
358
  codes: number
355
359
  authorizations: number
356
360
  links: number
@@ -11,12 +11,14 @@
11
11
 
12
12
  import { getDb } from './store'
13
13
  import type {
14
+ ConsumeOidcRefreshTokenResult,
14
15
  OidcAccessToken,
15
16
  OidcAuthorization,
16
17
  OidcClient,
17
18
  OidcClientLaunch,
18
19
  OidcCode,
19
20
  OidcKeyRow,
21
+ OidcRefreshToken,
20
22
  } from '../../store'
21
23
 
22
24
  function toOidcClient(row: Record<string, unknown>): OidcClient {
@@ -235,7 +237,7 @@ export function createOidcAccessToken(input: {
235
237
 
236
238
  export function getOidcAccessToken(token: string): OidcAccessToken | null {
237
239
  const row = getDb().prepare(
238
- "SELECT * FROM oidc_access_tokens WHERE token = ? AND expires_at > datetime('now')",
240
+ "SELECT * FROM oidc_access_tokens WHERE token = ? AND datetime(expires_at) > datetime('now')",
239
241
  ).get(token) as Record<string, unknown> | undefined
240
242
  if (!row) return null
241
243
  return {
@@ -249,6 +251,109 @@ export function getOidcAccessToken(token: string): OidcAccessToken | null {
249
251
  }
250
252
  }
251
253
 
254
+ /** The account console's per-app read + the governance view's per-user
255
+ * slice: the account's LIVE access tokens, newest first — created_at is
256
+ * second-resolution, the rowid breaks the tie. */
257
+ export function listOidcAccessTokens(userId: string): OidcAccessToken[] {
258
+ const rows = getDb().prepare(
259
+ "SELECT * FROM oidc_access_tokens WHERE user_id = ? AND datetime(expires_at) > datetime('now') ORDER BY created_at DESC, rowid DESC",
260
+ ).all(userId) as Array<Record<string, unknown>>
261
+ return rows.map(row => ({
262
+ token: row.token as string,
263
+ userId: row.user_id as string,
264
+ clientId: row.client_id as string,
265
+ scope: row.scope as string,
266
+ contextOrg: (row.context_org as string | null) ?? null,
267
+ amr: parseJsonStringList(row.amr),
268
+ expiresAt: row.expires_at as string,
269
+ }))
270
+ }
271
+
272
+ /** The RFC 7009 access-token revocation: the row goes, client-bound. */
273
+ export function deleteOidcAccessToken(token: string, clientId: string): boolean {
274
+ const res = getDb().prepare('DELETE FROM oidc_access_tokens WHERE token = ? AND client_id = ?').run(token, clientId)
275
+ return res.changes > 0
276
+ }
277
+
278
+ function toOidcRefreshToken(row: Record<string, unknown>): OidcRefreshToken {
279
+ return {
280
+ token: row.token as string,
281
+ userId: row.user_id as string,
282
+ clientId: row.client_id as string,
283
+ scope: row.scope as string,
284
+ contextOrg: (row.context_org as string | null) ?? null,
285
+ amr: parseJsonStringList(row.amr),
286
+ authTime: (row.auth_time as string | null) ?? null,
287
+ familyId: row.family_id as string,
288
+ createdAt: row.created_at as string,
289
+ expiresAt: row.expires_at as string,
290
+ consumedAt: (row.consumed_at as string | null) ?? null,
291
+ }
292
+ }
293
+
294
+ /** The refresh mint (migration 0025): the row carries the granting code's
295
+ * provenance verbatim — a rotation re-mints the SAME truth, auth_time
296
+ * never advances. */
297
+ export function createOidcRefreshToken(input: {
298
+ token: string
299
+ userId: string
300
+ clientId: string
301
+ scope: string
302
+ contextOrg?: string | null
303
+ amr?: string[] | null
304
+ authTime?: string | null
305
+ familyId: string
306
+ ttlMs: number
307
+ }): OidcRefreshToken {
308
+ const expiresAt = new Date(Date.now() + input.ttlMs).toISOString()
309
+ getDb().prepare(`
310
+ INSERT INTO oidc_refresh_tokens (token, user_id, client_id, scope, context_org, amr, auth_time, family_id, expires_at)
311
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
312
+ `).run(input.token, input.userId, input.clientId, input.scope, input.contextOrg ?? null,
313
+ input.amr?.length ? JSON.stringify(input.amr) : null, input.authTime ?? null, input.familyId, expiresAt)
314
+ return toOidcRefreshToken(
315
+ getDb().prepare('SELECT * FROM oidc_refresh_tokens WHERE token = ?').get(input.token) as Record<string, unknown>,
316
+ )
317
+ }
318
+
319
+ /** The refresh exchange's consume: the UPDATE flips consumed_at exactly
320
+ * once — a replay loses the race, reads the consumed row back, and the
321
+ * WHOLE FAMILY dies (the theft signal, RFC 6819 §5.2.2.3; a concurrent
322
+ * double-present lands the same verdict — fail toward invalidation). An
323
+ * expired live row is consumed anyway (never a second chance) and
324
+ * answers 'invalid'. */
325
+ export function consumeOidcRefreshToken(token: string): ConsumeOidcRefreshTokenResult {
326
+ const db = getDb()
327
+ const res = db.prepare("UPDATE oidc_refresh_tokens SET consumed_at = datetime('now') WHERE token = ? AND consumed_at IS NULL").run(token)
328
+ if (res.changes > 0) {
329
+ const row = db.prepare('SELECT * FROM oidc_refresh_tokens WHERE token = ?').get(token) as Record<string, unknown>
330
+ if (new Date(row.expires_at as string).getTime() <= Date.now()) return { kind: 'invalid' }
331
+ return { kind: 'ok', token: toOidcRefreshToken(row) }
332
+ }
333
+ const row = db.prepare('SELECT * FROM oidc_refresh_tokens WHERE token = ?').get(token) as Record<string, unknown> | undefined
334
+ if (!row) return { kind: 'invalid' }
335
+ // The reuse signal: the consumed row's family dies outright.
336
+ const familyId = row.family_id as string
337
+ db.prepare('DELETE FROM oidc_refresh_tokens WHERE family_id = ?').run(familyId)
338
+ return { kind: 'reuse', familyId, userId: row.user_id as string, clientId: row.client_id as string }
339
+ }
340
+
341
+ /** The RFC 7009 refresh revocation, client-bound: the presented token's
342
+ * family goes (never another client's rows). */
343
+ export function revokeOidcRefreshToken(token: string, clientId: string): boolean {
344
+ const db = getDb()
345
+ const row = db.prepare('SELECT family_id FROM oidc_refresh_tokens WHERE token = ? AND client_id = ?').get(token, clientId) as { family_id: string } | undefined
346
+ if (!row) return false
347
+ db.prepare('DELETE FROM oidc_refresh_tokens WHERE family_id = ?').run(row.family_id)
348
+ return true
349
+ }
350
+
351
+ /** The consent revocation's companion: the (account, client) pair's
352
+ * refresh rows all go. */
353
+ export function deleteOidcRefreshTokensForUserClient(userId: string, clientId: string): number {
354
+ return getDb().prepare('DELETE FROM oidc_refresh_tokens WHERE user_id = ? AND client_id = ?').run(userId, clientId).changes
355
+ }
356
+
252
357
  /** The amr column's honest parse (a JSON array of strings, else null —
253
358
  * the provenance is absent on rows that predate the wave). */
254
359
  function parseJsonStringList(raw: unknown): string[] | null {
@@ -132,6 +132,12 @@ CREATE TABLE IF NOT EXISTS entity_changes (
132
132
  id TEXT NOT NULL,
133
133
  at TEXT NOT NULL DEFAULT (datetime('now'))
134
134
  );
135
+ -- The per-store high-water's walk (migration 0026, the seam's
136
+ -- latestChangeSeqFor(store)): MAX(seq) over one store's slice of the
137
+ -- one global journal costs a single indexed probe, never a journal
138
+ -- scan. The seq stays global and monotone — a projection, never a
139
+ -- second sequence.
140
+ CREATE INDEX IF NOT EXISTS idx_entity_changes_store_seq ON entity_changes (store, seq);
135
141
 
136
142
  -- TODO.notify/01 — the platform event store (the notification system's
137
143
  -- source of truth): one row per DECLARED notifiable act (the catalog,
@@ -364,6 +370,31 @@ CREATE TABLE IF NOT EXISTS oidc_access_tokens (
364
370
  created_at TEXT NOT NULL DEFAULT (datetime('now'))
365
371
  );
366
372
 
373
+ -- The refresh tokens (migration 0025 — the SSO wave-C token surface): the
374
+ -- offline half of the remembered consent. One-time, atomically consumed
375
+ -- (WHERE consumed_at IS NULL); every rotation inherits the first mint's
376
+ -- family_id, and a presented CONSUMED token is the theft signal that kills
377
+ -- the whole family. The row carries the granting code's provenance
378
+ -- (canonical scope, context_org, amr, the ORIGINAL auth_time — a refresh
379
+ -- never advances it). Consumed rows STAY for the reuse detector;
380
+ -- revocation (client-bound, per family), the consent's revocation, the
381
+ -- deactivation sweep and the erasure remove rows outright.
382
+ CREATE TABLE IF NOT EXISTS oidc_refresh_tokens (
383
+ token TEXT PRIMARY KEY,
384
+ user_id TEXT NOT NULL REFERENCES users(id),
385
+ client_id TEXT NOT NULL,
386
+ scope TEXT NOT NULL,
387
+ context_org TEXT,
388
+ amr TEXT,
389
+ auth_time TEXT,
390
+ family_id TEXT NOT NULL,
391
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
392
+ expires_at TEXT NOT NULL,
393
+ consumed_at TEXT
394
+ );
395
+ CREATE INDEX IF NOT EXISTS idx_oidc_refresh_tokens_user ON oidc_refresh_tokens (user_id);
396
+ CREATE INDEX IF NOT EXISTS idx_oidc_refresh_tokens_family ON oidc_refresh_tokens (family_id);
397
+
367
398
  -- The OP's signing-key rotation history: the PUBLIC halves. JWKS serves
368
399
  -- every row not retired beyond the token lifetime, so a rotation never
369
400
  -- strands an in-flight ID token. The private half rides the
@@ -84,6 +84,7 @@ import {
84
84
  deleteEntity,
85
85
  getEntity,
86
86
  latestChangeSeq,
87
+ latestChangeSeqFor,
87
88
  listEntities,
88
89
  putEntity,
89
90
  } from './sqlite/entities'
@@ -117,16 +118,22 @@ import {
117
118
  } from './sqlite/notify'
118
119
  import {
119
120
  consumeOidcCode,
121
+ consumeOidcRefreshToken,
120
122
  createOidcAccessToken,
121
123
  createOidcAuthorization,
122
124
  createOidcCode,
125
+ createOidcRefreshToken,
123
126
  decideOidcAuthorization,
127
+ deleteOidcAccessToken,
128
+ deleteOidcRefreshTokensForUserClient,
124
129
  getOidcAccessToken,
125
130
  getOidcAuthorization,
126
131
  getOidcClient,
132
+ listOidcAccessTokens,
127
133
  listOidcClients,
128
134
  listOidcKeys,
129
135
  retireOidcKey,
136
+ revokeOidcRefreshToken,
130
137
  setOidcClientStatus,
131
138
  setOidcClientLaunch,
132
139
  upsertOidcClient,
@@ -179,7 +186,7 @@ import {
179
186
  updateOpAccount,
180
187
  updateUserName,
181
188
  } from './sqlite/op-accounts-store'
182
- import { installStore, type AccountEmail, type AddAccountEmailResult, type AuthUserPayload, type CertificateHolderClaim, type CertificateHolderOrg, type CompleteEmailChangeResult, type CompleteEnrollmentResult, type EmailChangeToken, type EnrollmentToken, type EntityChange, type EntityRow, type EventEntityKey, type EventKeyFilter, type FederationPeer, type IdentityApproval, type IdentityLink, type IdentityProvider, type InstrumentRegistration, type InstrumentRegistrationLifecycle, type InstrumentRegistrationScopeStatus, type NotifyDelivery, type NotifyDeliveryStatus, type NotifyEntityMute, type NotifyInboxState, type NotifyPreferences, type NotifyRule, type OAuthInitialAssignment, type OidcAccessToken, type OidcAuthorization, type OidcClient, type OidcClientLaunch, type OidcCode, type OidcConsentGrant, type OidcKeyRow, type OpAccountErasure, type OpClientRoleAssignment, type OpLiveSession, type OrgJoinRequest, type OrgMembership, type OrgMembershipState, type OrgRegistryContact, type OrgRegistryOrg, type OrgRegistryState, type PersonalAccessToken, type PlatformEvent, type ServerStore, type SessionView, type SsoSignInState, type UserAdminRow, type AdvanceCounterResult, type MfaPending, type RecoveryCodeState, type TotpSecret, type WebauthnChallenge, type WebauthnCredential } from '../store'
189
+ import { installStore, type AccountEmail, type AddAccountEmailResult, type AuthUserPayload, type CertificateHolderClaim, type CertificateHolderOrg, type CompleteEmailChangeResult, type CompleteEnrollmentResult, type ConsumeOidcRefreshTokenResult, type EmailChangeToken, type EnrollmentToken, type EntityChange, type EntityRow, type EventEntityKey, type EventKeyFilter, type FederationPeer, type IdentityApproval, type IdentityLink, type IdentityProvider, type InstrumentRegistration, type InstrumentRegistrationLifecycle, type InstrumentRegistrationScopeStatus, type NotifyDelivery, type NotifyDeliveryStatus, type NotifyEntityMute, type NotifyInboxState, type NotifyPreferences, type NotifyRule, type OAuthInitialAssignment, type OidcAccessToken, type OidcAuthorization, type OidcClient, type OidcClientLaunch, type OidcCode, type OidcConsentGrant, type OidcKeyRow, type OidcRefreshToken, type OpAccountErasure, type OpClientRoleAssignment, type OpLiveSession, type OrgJoinRequest, type OrgMembership, type OrgMembershipState, type OrgRegistryContact, type OrgRegistryOrg, type OrgRegistryState, type PersonalAccessToken, type PlatformEvent, type ServerStore, type SessionView, type SsoSignInState, type UserAdminRow, type AdvanceCounterResult, type MfaPending, type RecoveryCodeState, type TotpSecret, type WebauthnChallenge, type WebauthnCredential } from '../store'
183
190
  import {
184
191
  advanceWebauthnCounter,
185
192
  consumeMfaPending,
@@ -658,6 +665,34 @@ export function createSqliteServerStore(): ServerStore {
658
665
  async getOidcAccessToken(token: string): Promise<OidcAccessToken | null> {
659
666
  return getOidcAccessToken(token)
660
667
  },
668
+ async listOidcAccessTokens(userId: string): Promise<OidcAccessToken[]> {
669
+ return listOidcAccessTokens(userId)
670
+ },
671
+ async deleteOidcAccessToken(token: string, clientId: string): Promise<boolean> {
672
+ return deleteOidcAccessToken(token, clientId)
673
+ },
674
+ async createOidcRefreshToken(input: {
675
+ token: string
676
+ userId: string
677
+ clientId: string
678
+ scope: string
679
+ contextOrg?: string | null
680
+ amr?: string[] | null
681
+ authTime?: string | null
682
+ familyId: string
683
+ ttlMs: number
684
+ }): Promise<OidcRefreshToken> {
685
+ return createOidcRefreshToken(input)
686
+ },
687
+ async consumeOidcRefreshToken(token: string): Promise<ConsumeOidcRefreshTokenResult> {
688
+ return consumeOidcRefreshToken(token)
689
+ },
690
+ async revokeOidcRefreshToken(token: string, clientId: string): Promise<boolean> {
691
+ return revokeOidcRefreshToken(token, clientId)
692
+ },
693
+ async deleteOidcRefreshTokensForUserClient(userId: string, clientId: string): Promise<number> {
694
+ return deleteOidcRefreshTokensForUserClient(userId, clientId)
695
+ },
661
696
  async listOidcKeys(): Promise<OidcKeyRow[]> {
662
697
  return listOidcKeys()
663
698
  },
@@ -956,7 +991,7 @@ export function createSqliteServerStore(): ServerStore {
956
991
  async deleteOpClientRoles(userId: string, clientId: string): Promise<boolean> {
957
992
  return deleteOpClientRoles(userId, clientId)
958
993
  },
959
- async revokeOpUserCredentials(userId: string): Promise<{ sessions: number; accessTokens: number; codes: number; authorizations: number }> {
994
+ async revokeOpUserCredentials(userId: string): Promise<{ sessions: number; accessTokens: number; refreshTokens: number; codes: number; authorizations: number }> {
960
995
  return revokeOpUserCredentials(userId)
961
996
  },
962
997
  async eraseOpAccount(userId: string): Promise<OpAccountErasure | null> {
@@ -988,6 +1023,9 @@ export function createSqliteServerStore(): ServerStore {
988
1023
  async latestChangeSeq(): Promise<number> {
989
1024
  return latestChangeSeq()
990
1025
  },
1026
+ async latestChangeSeqFor(store: string): Promise<number> {
1027
+ return latestChangeSeqFor(store)
1028
+ },
991
1029
 
992
1030
  // ── the platform event store (TODO.notify/01) ──
993
1031
  async appendEvent(input: {
package/src/store.ts CHANGED
@@ -454,6 +454,41 @@ export interface OidcAccessToken {
454
454
  expiresAt: string
455
455
  }
456
456
 
457
+ /** An issued refresh token (migration 0025 — the SSO wave-C token
458
+ * surface): the offline half of the remembered consent. The row carries
459
+ * the granting code's full provenance — the canonical scope spelling, the
460
+ * context_org, the amr, and the ORIGINAL authentication instant (authTime
461
+ * never advances on a refresh: the refreshed ID token proves the original
462
+ * authentication). familyId is the rotation lineage: every rotation
463
+ * inherits the first mint's id. consumedAt flips atomically at the
464
+ * exchange; the consumed row STAYS so a re-present detects the reuse. */
465
+ export interface OidcRefreshToken {
466
+ token: string
467
+ userId: string
468
+ clientId: string
469
+ scope: string
470
+ contextOrg: string | null
471
+ amr: string[] | null
472
+ authTime: string | null
473
+ familyId: string
474
+ createdAt: string
475
+ expiresAt: string
476
+ consumedAt: string | null
477
+ }
478
+
479
+ /** The refresh exchange's honest outcomes. 'ok' carries the freshly
480
+ * consumed row (the route rotates from its provenance). 'reuse' is the
481
+ * theft signal (RFC 6819 §5.2.2.3): a presented CONSUMED token — the
482
+ * store already killed the whole family (the attacker's copy and the
483
+ * legitimate chain both end); a concurrent double-present loses the
484
+ * atomic race to the same verdict, the fail-toward-invalidation posture.
485
+ * 'invalid' covers the never-existed, the revoked (the row is gone), and
486
+ * the expired (consumed anyway — never a second chance). */
487
+ export type ConsumeOidcRefreshTokenResult =
488
+ | { kind: 'ok'; token: OidcRefreshToken }
489
+ | { kind: 'reuse'; familyId: string; userId: string; clientId: string }
490
+ | { kind: 'invalid' }
491
+
457
492
  /** The OP key rotation history — the PUBLIC half only. */
458
493
  export interface OidcKeyRow {
459
494
  kid: string
@@ -609,7 +644,7 @@ export interface OpClientRoleAssignment {
609
644
  }
610
645
 
611
646
  /** The erasure act's removal counts (the audit event's metadata):
612
- * revokeOpUserCredentials' four plus the links, the per-client
647
+ * revokeOpUserCredentials' five plus the links, the per-client
613
648
  * assignments, the org memberships (TODO.identity/11), and the
614
649
  * credential/token rows (passwords, enrollment tokens, email-change
615
650
  * tokens). TODO.identity-sso/02+03 adds `factors`: the factor-registry
@@ -624,6 +659,7 @@ export interface OpClientRoleAssignment {
624
659
  export interface OpAccountErasure {
625
660
  sessions: number
626
661
  accessTokens: number
662
+ refreshTokens: number
627
663
  codes: number
628
664
  authorizations: number
629
665
  links: number
@@ -1617,6 +1653,50 @@ export interface ServerStore {
1617
1653
  ttlMs: number
1618
1654
  }): Promise<void>
1619
1655
  getOidcAccessToken(token: string): Promise<OidcAccessToken | null>
1656
+ /** The account's LIVE access tokens (unexpired, never revoked — the
1657
+ * row's absence IS the revocation), newest first. The account console's
1658
+ * per-app read and the client-registry governance view's per-user
1659
+ * slice; the introspection endpoint never lists (it resolves ONE
1660
+ * presented token). */
1661
+ listOidcAccessTokens(userId: string): Promise<OidcAccessToken[]>
1662
+ /** The RFC 7009 access-token revocation: delete the row, client-bound —
1663
+ * a client revokes only its OWN tokens (a token minted for another
1664
+ * client answers false). An absent row answers false too (the endpoint
1665
+ * masks both behind its 200). */
1666
+ deleteOidcAccessToken(token: string, clientId: string): Promise<boolean>
1667
+ /** The refresh tokens (migration 0025 — the SSO wave-C token surface).
1668
+ * The mint: token is the opaque value the route generated, familyId the
1669
+ * rotation lineage (a fresh id at the code exchange's first mint, the
1670
+ * consumed row's familyId at every rotation). scope is the CANONICAL
1671
+ * spelling of the granted set (a refresh never widens it). authTime is
1672
+ * the ORIGINAL authentication instant — it never advances. */
1673
+ createOidcRefreshToken(input: {
1674
+ token: string
1675
+ userId: string
1676
+ clientId: string
1677
+ scope: string
1678
+ contextOrg?: string | null
1679
+ amr?: string[] | null
1680
+ authTime?: string | null
1681
+ familyId: string
1682
+ ttlMs: number
1683
+ }): Promise<OidcRefreshToken>
1684
+ /** The refresh exchange's consume: the UPDATE … WHERE consumed_at IS
1685
+ * NULL flips exactly once (the oidc_codes doctrine). A presented
1686
+ * CONSUMED token is the reuse signal — the whole family is deleted
1687
+ * before the verdict answers. An expired live row is consumed anyway
1688
+ * and answers 'invalid'. */
1689
+ consumeOidcRefreshToken(token: string): Promise<ConsumeOidcRefreshTokenResult>
1690
+ /** The RFC 7009 refresh-token revocation, client-bound: the presented
1691
+ * token's WHOLE FAMILY goes (the grant lineage ends), never another
1692
+ * client's rows. Answers false when the token never existed, was
1693
+ * already gone, or belongs to another client (the endpoint masks all
1694
+ * three behind its 200). */
1695
+ revokeOidcRefreshToken(token: string, clientId: string): Promise<boolean>
1696
+ /** The consent revocation's companion: every refresh row of the
1697
+ * (account, client) pair goes — the "Revoke access" act ends the
1698
+ * offline half with the remembered consent. Answers the count. */
1699
+ deleteOidcRefreshTokensForUserClient(userId: string, clientId: string): Promise<number>
1620
1700
  /** The key rotation history (public halves). */
1621
1701
  listOidcKeys(): Promise<OidcKeyRow[]>
1622
1702
  upsertOidcKey(input: { kid: string; publicJwk: string }): Promise<void>
@@ -1763,10 +1843,12 @@ export interface ServerStore {
1763
1843
  /** Clear the per-client assignment (the account default is restored). */
1764
1844
  deleteOpClientRoles(userId: string, clientId: string): Promise<boolean>
1765
1845
  /** The deactivation's revocation half: delete EVERY live session, every
1766
- * issued OIDC access token, every unconsumed authorization code and
1767
- * every pending authorization of the account. Answers the counts (the
1768
- * audit event's metadata). The user row STAYS (the history). */
1769
- revokeOpUserCredentials(userId: string): Promise<{ sessions: number; accessTokens: number; codes: number; authorizations: number }>
1846
+ * issued OIDC access token, every refresh token (consumed or not — the
1847
+ * reuse detector has no more verdicts to give), every unconsumed
1848
+ * authorization code and every pending authorization of the account.
1849
+ * Answers the counts (the audit event's metadata). The user row STAYS
1850
+ * (the history). */
1851
+ revokeOpUserCredentials(userId: string): Promise<{ sessions: number; accessTokens: number; refreshTokens: number; codes: number; authorizations: number }>
1770
1852
  /** The offboarding runbook's DELETE path (the erasure): every credential,
1771
1853
  * token, link and per-client assignment removed, the user row anonymized
1772
1854
  * in place (provider 'erased' — it drops out of every account surface;
@@ -2271,20 +2353,20 @@ export interface ServerStore {
2271
2353
  changesAfter(seq: number, limit?: number): Promise<EntityChange[]>
2272
2354
  /** The GLOBAL journal high-water: the bootstrap snapshot's ETag leg
2273
2355
  * (any write anywhere bumps the one seq — conservative, never
2274
- * stale).
2275
- *
2276
- * NAMED FOLLOW-UP, DOC ONLY (the smart side references it from
2277
- * server/routes/bootstrap.ts: the per-store `seqs` snapshot carries
2278
- * the journal position a delta-loading client would resume from):
2279
- * the PER-STORE high-water — `latestChangeSeqFor(store)` — answering
2280
- * MAX(seq) over one store's slice of the SAME journal. The contract
2281
- * when it lands: the seq stays global and monotone (the per-store
2282
- * read is a PROJECTION of the one journal, never a second
2283
- * sequence); it exists so a per-store conditional revalidation /
2284
- * SSE resume probe costs one indexed read instead of a payload
2285
- * fetch; and its query wants the (store, seq) walk — the index
2286
- * rides the same commit, expand-only per the migration contract. */
2356
+ * stale). */
2287
2357
  latestChangeSeq(): Promise<number>
2358
+ /** The PER-STORE high-water: MAX(seq) over one store's slice of the
2359
+ * SAME journal. The seq stays global and monotone — the per-store
2360
+ * read is a PROJECTION of the one journal, never a second sequence.
2361
+ * A store with no writes answers 0 (the empty journal's floor, the
2362
+ * same as the global form's). The read is one indexed probe over
2363
+ * idx_entity_changes_store_seq (migration 0026 — the (store, seq)
2364
+ * walk rode that commit, expand-only per the migration contract),
2365
+ * so a per-store conditional revalidation / SSE resume probe never
2366
+ * pays a journal scan. Landed for the 2026-09-07 performance
2367
+ * audit's G1: the smart side's bootstrap composite ETag switches
2368
+ * from the global seq to the per-set composite of these. */
2369
+ latestChangeSeqFor(store: string): Promise<number>
2288
2370
 
2289
2371
  // ── the platform event store (TODO.notify/01) ──
2290
2372
  /** Append one declared event (the emitter's write; one row inside the