@saasicat/spec 1.0.0-rc.22 → 1.0.0-rc.23

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.
@@ -29,7 +29,7 @@ openapi: 3.1.0
29
29
 
30
30
  info:
31
31
  title: SaaS Platform SuperAdmin API
32
- version: 1.0.0-rc.22
32
+ version: 1.0.0-rc.23
33
33
  description: |
34
34
  Read and write operations for platform administration:
35
35
  tenants, users, subscriptions, promo codes, audit log,
@@ -58,6 +58,7 @@ tags:
58
58
  - name: manifest
59
59
  - name: catalog
60
60
  - name: settings
61
+ - name: maintenance
61
62
 
62
63
  security:
63
64
  - bearerAuth: []
@@ -1406,6 +1407,146 @@ paths:
1406
1407
  '404':
1407
1408
  description: No recorded change has this id (`SETTINGS_CHANGE_NOT_FOUND`)
1408
1409
 
1410
+ # ── Maintenance windows (MaintenanceModule, packages/nest/src/maintenance/*) ──
1411
+ #
1412
+ # An operator announces a window, locks the application for a deploy and
1413
+ # unlocks it. The announced times are what tenants are told; they never
1414
+ # start or end the lock. These routes stay reachable while the lock holds.
1415
+ # The status a tenant's page reads is `GET /public/maintenance`, outside
1416
+ # this document's prefix.
1417
+
1418
+ /maintenance:
1419
+ get:
1420
+ tags: [maintenance]
1421
+ summary: The open window, if any, and the ones before it
1422
+ responses:
1423
+ '200':
1424
+ description: The overview, read fresh
1425
+ content:
1426
+ application/json:
1427
+ schema: { $ref: '#/components/schemas/MaintenanceOverview' }
1428
+ post:
1429
+ tags: [maintenance]
1430
+ summary: Announce a window to the tenants
1431
+ description: >-
1432
+ Locks nobody out. Refused while another window is open. The application hears
1433
+ of it through its `MaintenanceNotificationPort`, where it binds one. Audited as
1434
+ MAINTENANCE_WINDOW_ANNOUNCE.
1435
+ requestBody:
1436
+ required: true
1437
+ content:
1438
+ application/json:
1439
+ schema: { $ref: '#/components/schemas/MaintenanceAnnouncement' }
1440
+ responses:
1441
+ '201':
1442
+ description: The window, announced
1443
+ content:
1444
+ application/json:
1445
+ schema: { $ref: '#/components/schemas/MaintenanceWindowView' }
1446
+ '400':
1447
+ description: >-
1448
+ A time without its zone (`MAINTENANCE_TIME_INVALID`), an end not after the
1449
+ start (`MAINTENANCE_WINDOW_END_NOT_AFTER_START`) or in the past
1450
+ (`MAINTENANCE_WINDOW_END_IN_PAST`), a message too long
1451
+ (`MAINTENANCE_MESSAGE_TOO_LONG`)
1452
+ '409':
1453
+ description: Another window is open (`MAINTENANCE_WINDOW_ALREADY_OPEN`)
1454
+
1455
+ /maintenance/{id}:
1456
+ parameters: [{ name: id, in: path, required: true, schema: { type: string } }]
1457
+ patch:
1458
+ tags: [maintenance]
1459
+ summary: Move the open window, or change its message
1460
+ description: >-
1461
+ What is left out stays; `message: null` takes the message away. A locked window
1462
+ keeps its start and may still move its expected end. Audited as
1463
+ MAINTENANCE_WINDOW_RESCHEDULE.
1464
+ requestBody:
1465
+ required: true
1466
+ content:
1467
+ application/json:
1468
+ schema: { $ref: '#/components/schemas/MaintenanceRevision' }
1469
+ responses:
1470
+ '200':
1471
+ description: The window as it now stands
1472
+ content:
1473
+ application/json:
1474
+ schema: { $ref: '#/components/schemas/MaintenanceWindowView' }
1475
+ '400':
1476
+ description: As for announcing
1477
+ '404':
1478
+ description: No open window has this id (`MAINTENANCE_WINDOW_NOT_OPEN`)
1479
+ '409':
1480
+ description: The window is locked and its start cannot move (`MAINTENANCE_WINDOW_LOCKED`)
1481
+
1482
+ /maintenance/{id}/cancel:
1483
+ parameters: [{ name: id, in: path, required: true, schema: { type: string } }]
1484
+ post:
1485
+ tags: [maintenance]
1486
+ summary: Call off an announced window
1487
+ description: Audited as MAINTENANCE_WINDOW_CANCEL. A locked window is ended by unlocking it.
1488
+ responses:
1489
+ '201':
1490
+ description: The window, cancelled
1491
+ content:
1492
+ application/json:
1493
+ schema: { $ref: '#/components/schemas/MaintenanceWindowView' }
1494
+ '404':
1495
+ description: No open window has this id (`MAINTENANCE_WINDOW_NOT_OPEN`)
1496
+ '409':
1497
+ description: The window is locked (`MAINTENANCE_WINDOW_LOCKED`)
1498
+
1499
+ /maintenance/lock:
1500
+ post:
1501
+ tags: [maintenance]
1502
+ summary: Lock every tenant out
1503
+ description: >-
1504
+ Locks the open window, or opens one locked at once where none is open. Locking what
1505
+ is locked changes nothing and says so. Takes effect in every process within
1506
+ `takesEffectWithinSeconds`. Audited as MAINTENANCE_LOCK.
1507
+ x-mfa-required: true
1508
+ requestBody:
1509
+ required: false
1510
+ content:
1511
+ application/json:
1512
+ schema: { $ref: '#/components/schemas/MaintenanceLockRequest' }
1513
+ responses:
1514
+ '201':
1515
+ description: The lock
1516
+ content:
1517
+ application/json:
1518
+ schema: { $ref: '#/components/schemas/MaintenanceLockView' }
1519
+ '400':
1520
+ description: An end without its zone or in the past
1521
+ '404':
1522
+ description: The window named is not the open one (`MAINTENANCE_WINDOW_NOT_OPEN`)
1523
+
1524
+ /maintenance/unlock:
1525
+ post:
1526
+ tags: [maintenance]
1527
+ summary: Let tenants back in
1528
+ description: >-
1529
+ Ends the locked window. Where nothing is locked it changes nothing and says so; an
1530
+ announced window is left as it is. Audited as MAINTENANCE_UNLOCK.
1531
+ x-mfa-required: true
1532
+ requestBody:
1533
+ required: false
1534
+ content:
1535
+ application/json:
1536
+ schema:
1537
+ type: object
1538
+ properties:
1539
+ windowId:
1540
+ { type: string, description: The window the operator means }
1541
+ responses:
1542
+ '201':
1543
+ description: What the unlock did
1544
+ content:
1545
+ application/json:
1546
+ schema: { $ref: '#/components/schemas/MaintenanceUnlockView' }
1547
+ '404':
1548
+ description: The window named is not the open one (`MAINTENANCE_WINDOW_NOT_OPEN`)
1549
+
1409
1550
  # ── Discovery (scan/status, outside /catalog) ──
1410
1551
  /discovery:
1411
1552
  get:
@@ -1514,6 +1655,123 @@ components:
1514
1655
  before: { description: 'Absent where the leaf did not exist before' }
1515
1656
  after: { description: 'Absent where the leaf no longer exists' }
1516
1657
 
1658
+ # ────────── Maintenance windows (packages/core/src/maintenance-window.types.ts) ──────────
1659
+ MaintenanceWindowView:
1660
+ type: object
1661
+ required:
1662
+ [
1663
+ id,
1664
+ status,
1665
+ startsAt,
1666
+ endsAt,
1667
+ message,
1668
+ createdAt,
1669
+ createdBy,
1670
+ lockedAt,
1671
+ lockedBy,
1672
+ endedAt,
1673
+ endedBy,
1674
+ overrun,
1675
+ lapsed,
1676
+ ]
1677
+ properties:
1678
+ id: { type: string }
1679
+ status: { type: string, enum: [announced, locked, ended, cancelled] }
1680
+ startsAt:
1681
+ {
1682
+ type: [string, 'null'],
1683
+ format: date-time,
1684
+ description: 'The announced start; null for a window locked without being announced',
1685
+ }
1686
+ endsAt:
1687
+ {
1688
+ type: [string, 'null'],
1689
+ format: date-time,
1690
+ description: 'The announced, expected end; null where nobody said',
1691
+ }
1692
+ message: { type: [string, 'null'], description: 'Shown to the tenants as written' }
1693
+ createdAt: { type: string, format: date-time }
1694
+ createdBy: { type: string, description: 'An actor tag, as the audit log writes it' }
1695
+ lockedAt: { type: [string, 'null'], format: date-time }
1696
+ lockedBy: { type: [string, 'null'] }
1697
+ endedAt: { type: [string, 'null'], format: date-time }
1698
+ endedBy: { type: [string, 'null'] }
1699
+ overrun: { type: boolean, description: 'Locked, and past its announced end' }
1700
+ lapsed:
1701
+ {
1702
+ type: boolean,
1703
+ description: 'Announced, never locked, and past its announced end: tenants no longer see it',
1704
+ }
1705
+
1706
+ MaintenanceOverview:
1707
+ type: object
1708
+ required: [open, recent, takesEffectWithinSeconds]
1709
+ properties:
1710
+ open:
1711
+ oneOf:
1712
+ - $ref: '#/components/schemas/MaintenanceWindowView'
1713
+ - type: 'null'
1714
+ recent:
1715
+ type: array
1716
+ description: 'Newest first, the open one included'
1717
+ items: { $ref: '#/components/schemas/MaintenanceWindowView' }
1718
+ takesEffectWithinSeconds:
1719
+ { type: integer, description: 'How long a lock takes to reach every process' }
1720
+
1721
+ MaintenanceAnnouncement:
1722
+ type: object
1723
+ required: [startsAt, endsAt]
1724
+ properties:
1725
+ startsAt:
1726
+ {
1727
+ type: string,
1728
+ description: 'Date and time with its zone, e.g. 2026-10-02T22:00+02:00',
1729
+ }
1730
+ endsAt:
1731
+ {
1732
+ type: string,
1733
+ description: 'Date and time with its zone; after startsAt, and ahead',
1734
+ }
1735
+ message: { type: [string, 'null'], maxLength: 500 }
1736
+
1737
+ MaintenanceRevision:
1738
+ type: object
1739
+ properties:
1740
+ startsAt: { type: string, description: 'Date and time with its zone' }
1741
+ endsAt: { type: string, description: 'Date and time with its zone' }
1742
+ message:
1743
+ { type: [string, 'null'], maxLength: 500, description: 'null takes it away' }
1744
+
1745
+ MaintenanceLockRequest:
1746
+ type: object
1747
+ properties:
1748
+ windowId:
1749
+ {
1750
+ type: string,
1751
+ description: 'The window the operator means; left out, the open one or a new one',
1752
+ }
1753
+ endsAt: { type: string, description: 'The expected end, with its zone' }
1754
+ message: { type: [string, 'null'], maxLength: 500 }
1755
+
1756
+ MaintenanceLockView:
1757
+ type: object
1758
+ required: [window, alreadyLocked, takesEffectWithinSeconds]
1759
+ properties:
1760
+ window: { $ref: '#/components/schemas/MaintenanceWindowView' }
1761
+ alreadyLocked:
1762
+ { type: boolean, description: 'The lock already held; nothing changed' }
1763
+ takesEffectWithinSeconds: { type: integer }
1764
+
1765
+ MaintenanceUnlockView:
1766
+ type: object
1767
+ required: [window, wasLocked]
1768
+ properties:
1769
+ window:
1770
+ oneOf:
1771
+ - $ref: '#/components/schemas/MaintenanceWindowView'
1772
+ - type: 'null'
1773
+ wasLocked: { type: boolean }
1774
+
1517
1775
  # ────────── Catalog V2 (DTO = SSOT: packages/nest/src/catalog/dto/*) ──────────
1518
1776
  CatalogPlanCreate:
1519
1777
  type: object
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saasicat/spec",
3
- "version": "1.0.0-rc.22",
3
+ "version": "1.0.0-rc.23",
4
4
  "description": "Language-neutral spec of the SaaS platform: JSON Schemas, OpenAPI contract, Prisma fragments, acceptance scenarios.",
5
5
  "type": "module",
6
6
  "main": "./index.cjs",
@@ -0,0 +1,47 @@
1
+ // =============================================================================
2
+ // SaaS platform Prisma fragment: MaintenanceWindow
3
+ // =============================================================================
4
+ //
5
+ // REFERENCE SNIPPET — see 01-subscription.prisma for conventions.
6
+ //
7
+ // One row per maintenance window: announced to the tenants ahead of time,
8
+ // locked while a deploy runs a migration that must not race with requests, and
9
+ // ended when the operator says so. A window can also be locked at once without
10
+ // having been announced, which leaves `startsAt` null.
11
+ //
12
+ // While a window is locked, every running version of the application reads this
13
+ // table on its requests — the one being replaced as well as the one replacing
14
+ // it. Its shape is therefore part of the contract between two releases: it
15
+ // gains columns, and never in a deploy the lock protects.
16
+ //
17
+ // At most ONE window is open (not yet ended) at a time. Prisma cannot express a
18
+ // partial unique index; the one that holds it is in
19
+ // sql/constraints.postgres.sql, and it is what makes two operators locking at
20
+ // the same moment land one window rather than two.
21
+ //
22
+ // Optional: adopt it where `maintenance` is turned on in
23
+ // `SaaSiCatModule.forRoot`, which refuses to start without a place to keep
24
+ // windows.
25
+
26
+ model MaintenanceWindow {
27
+ id String @id @default(uuid())
28
+ // What the tenants were told: the announced start and the expected end.
29
+ // Neither starts or ends the lock — that is the operator's command.
30
+ startsAt DateTime?
31
+ endsAt DateTime?
32
+ // The operator's words to the tenants, shown as written.
33
+ message String?
34
+ // When the window was recorded — announced, or locked at once — and by whom
35
+ // (an actor tag, as the audit log writes it).
36
+ createdAt DateTime @default(now())
37
+ createdBy String
38
+ // Null while the window is only announced.
39
+ lockedAt DateTime?
40
+ lockedBy String?
41
+ // Set when the window is over: unlocked, or cancelled before it locked.
42
+ endedAt DateTime?
43
+ endedBy String?
44
+
45
+ @@index([createdAt])
46
+ @@map("maintenance_windows")
47
+ }
@@ -45,6 +45,7 @@ regenerated after fragment changes (`tests/reference-sql-drift.test.js`).
45
45
  | [`13-subscriber.prisma`](13-subscriber.prisma) | `Subscriber`, `SubscriberTenant`, `SubscriberCorrection` |
46
46
  | [`14-payments.prisma`](14-payments.prisma) | `SubscriberPaymentMethod`, `SubscriberPaymentMethodSetup`, `PaymentEventLog` |
47
47
  | [`15-subscriber-ledger.prisma`](15-subscriber-ledger.prisma) | `SubscriberLedgerEntry` |
48
+ | [`16-maintenance-window.prisma`](16-maintenance-window.prisma) | `MaintenanceWindow` |
48
49
 
49
50
  ## How the consumer uses the fragments
50
51
 
@@ -141,7 +141,8 @@
141
141
  "marketingCatalog": { "$ref": "#/$defs/StandardPageDef" },
142
142
  "platformEmail": { "$ref": "#/$defs/StandardPageDef" },
143
143
  "platformEmailHistory": { "$ref": "#/$defs/StandardPageDef" },
144
- "settings": { "$ref": "#/$defs/StandardPageDef" }
144
+ "settings": { "$ref": "#/$defs/StandardPageDef" },
145
+ "maintenance": { "$ref": "#/$defs/StandardPageDef" }
145
146
  }
146
147
  },
147
148
  "projectPages": {
@@ -0,0 +1,51 @@
1
+ -- =============================================================================
2
+ -- SaaSiCat 1.0 — maintenance windows are kept.
3
+ -- =============================================================================
4
+ --
5
+ -- One table, additive: `maintenance_windows` holds one row per maintenance
6
+ -- window — announced to the tenants, locked while a deploy runs, ended when the
7
+ -- operator unlocks. The platform reads it on every request while it is turned
8
+ -- on (`maintenance` in `SaaSiCatModule.forRoot`), through a cache of a few
9
+ -- seconds.
10
+ --
11
+ -- Run it the same way as the other files in this directory, before `db push`
12
+ -- where you use one:
13
+ --
14
+ -- psql "$DATABASE_URL" -f 1.0-maintenance-windows-are-kept.postgres.sql
15
+ --
16
+ -- Apply it in an ordinary deploy, BEFORE the first deploy you lock: the lock
17
+ -- protects a deploy only when the version already running reads it, so the
18
+ -- table has to be there, and that version with it, before it is needed.
19
+ --
20
+ -- The index on a constant is what holds an installation to one open window:
21
+ -- every open row carries the same key, so a second one collides, and a window
22
+ -- that ended leaves the index.
23
+ --
24
+ -- Safe to run again: the table and both indexes are created only where they are
25
+ -- missing. On a database created from `reference-schema.postgres.sql` the whole
26
+ -- file does nothing at all.
27
+
28
+ BEGIN;
29
+
30
+ CREATE TABLE IF NOT EXISTS "maintenance_windows" (
31
+ "id" TEXT NOT NULL,
32
+ "startsAt" TIMESTAMP(3),
33
+ "endsAt" TIMESTAMP(3),
34
+ "message" TEXT,
35
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
36
+ "createdBy" TEXT NOT NULL,
37
+ "lockedAt" TIMESTAMP(3),
38
+ "lockedBy" TEXT,
39
+ "endedAt" TIMESTAMP(3),
40
+ "endedBy" TEXT,
41
+
42
+ CONSTRAINT "maintenance_windows_pkey" PRIMARY KEY ("id")
43
+ );
44
+
45
+ CREATE INDEX IF NOT EXISTS "maintenance_windows_createdAt_idx"
46
+ ON "maintenance_windows"("createdAt");
47
+
48
+ CREATE UNIQUE INDEX IF NOT EXISTS maintenance_windows_one_open
49
+ ON maintenance_windows ((true)) WHERE "endedAt" IS NULL;
50
+
51
+ COMMIT;
@@ -47,6 +47,15 @@ ALTER TABLE applied_settings
47
47
  ALTER TABLE applied_settings
48
48
  ADD CONSTRAINT applied_settings_is_a_singleton CHECK ("id" = 'installation');
49
49
 
50
+ -- At most ONE maintenance window is open — announced or locked, not yet ended.
51
+ --
52
+ -- The index is on a constant, so every open row carries the same key and a
53
+ -- second one collides. A window that ended leaves the index and makes room for
54
+ -- the next. Two operators locking at the same moment therefore land one window,
55
+ -- because the second insert meets the first and is told a window is open.
56
+ CREATE UNIQUE INDEX IF NOT EXISTS maintenance_windows_one_open
57
+ ON maintenance_windows ((true)) WHERE "endedAt" IS NULL;
58
+
50
59
  -- A subscriber is live for at most ONE tenant, and a tenant has at most ONE
51
60
  -- live subscriber. A link that ended keeps its row with `unlinkedAt` set, so the
52
61
  -- tenants a subscriber had before stay in its history. Two partial unique
@@ -18,6 +18,7 @@
18
18
  -- prisma-fragments/13-subscriber.prisma
19
19
  -- prisma-fragments/14-payments.prisma
20
20
  -- prisma-fragments/15-subscriber-ledger.prisma
21
+ -- prisma-fragments/16-maintenance-window.prisma
21
22
  -- plus the normative constraints from sql/constraints.postgres.sql.
22
23
  -- Do not edit by hand — change the fragments/constraints and regenerate.
23
24
 
@@ -706,6 +707,22 @@ CREATE TABLE "subscriber_ledger_entries" (
706
707
  CONSTRAINT "subscriber_ledger_entries_pkey" PRIMARY KEY ("id")
707
708
  );
708
709
 
710
+ -- CreateTable
711
+ CREATE TABLE "maintenance_windows" (
712
+ "id" TEXT NOT NULL,
713
+ "startsAt" TIMESTAMP(3),
714
+ "endsAt" TIMESTAMP(3),
715
+ "message" TEXT,
716
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
717
+ "createdBy" TEXT NOT NULL,
718
+ "lockedAt" TIMESTAMP(3),
719
+ "lockedBy" TEXT,
720
+ "endedAt" TIMESTAMP(3),
721
+ "endedBy" TEXT,
722
+
723
+ CONSTRAINT "maintenance_windows_pkey" PRIMARY KEY ("id")
724
+ );
725
+
709
726
  -- CreateIndex
710
727
  CREATE UNIQUE INDEX "subscriptions_tenantId_key" ON "subscriptions"("tenantId");
711
728
 
@@ -940,6 +957,9 @@ CREATE INDEX "subscriber_ledger_entries_contractLineItemId_idx" ON "subscriber_l
940
957
  -- CreateIndex
941
958
  CREATE UNIQUE INDEX "subscriber_ledger_entries_subscriptionId_source_sourceRef_p_key" ON "subscriber_ledger_entries"("subscriptionId", "source", "sourceRef", "periodStart", "origin");
942
959
 
960
+ -- CreateIndex
961
+ CREATE INDEX "maintenance_windows_createdAt_idx" ON "maintenance_windows"("createdAt");
962
+
943
963
  -- AddForeignKey
944
964
  ALTER TABLE "subscriptions" ADD CONSTRAINT "subscriptions_planVersionId_fkey" FOREIGN KEY ("planVersionId") REFERENCES "plan_versions"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
945
965
 
@@ -1049,6 +1069,15 @@ ALTER TABLE applied_settings
1049
1069
  ALTER TABLE applied_settings
1050
1070
  ADD CONSTRAINT applied_settings_is_a_singleton CHECK ("id" = 'installation');
1051
1071
 
1072
+ -- At most ONE maintenance window is open — announced or locked, not yet ended.
1073
+ --
1074
+ -- The index is on a constant, so every open row carries the same key and a
1075
+ -- second one collides. A window that ended leaves the index and makes room for
1076
+ -- the next. Two operators locking at the same moment therefore land one window,
1077
+ -- because the second insert meets the first and is told a window is open.
1078
+ CREATE UNIQUE INDEX IF NOT EXISTS maintenance_windows_one_open
1079
+ ON maintenance_windows ((true)) WHERE "endedAt" IS NULL;
1080
+
1052
1081
  -- A subscriber is live for at most ONE tenant, and a tenant has at most ONE
1053
1082
  -- live subscriber. A link that ended keeps its row with `unlinkedAt` set, so the
1054
1083
  -- tenants a subscriber had before stay in its history. Two partial unique