@stapel/workspaces-react 0.16.0 → 0.19.0

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.
Files changed (128) hide show
  1. package/CHANGELOG.md +194 -0
  2. package/README.md +39 -8
  3. package/dist/api/generated/schema.d.ts +187 -60
  4. package/dist/api/generated/schema.d.ts.map +1 -1
  5. package/dist/default/ActiveWorkspace.d.ts +43 -0
  6. package/dist/default/ActiveWorkspace.d.ts.map +1 -0
  7. package/dist/default/ActiveWorkspace.js +33 -0
  8. package/dist/default/ActiveWorkspace.js.map +1 -0
  9. package/dist/default/AuditTrailPane.d.ts +12 -0
  10. package/dist/default/AuditTrailPane.d.ts.map +1 -0
  11. package/dist/default/AuditTrailPane.js +154 -0
  12. package/dist/default/AuditTrailPane.js.map +1 -0
  13. package/dist/default/InvitationsPane.d.ts +19 -0
  14. package/dist/default/InvitationsPane.d.ts.map +1 -0
  15. package/dist/default/InvitationsPane.js +217 -0
  16. package/dist/default/InvitationsPane.js.map +1 -0
  17. package/dist/default/InviteAcceptPage.d.ts +7 -0
  18. package/dist/default/InviteAcceptPage.d.ts.map +1 -1
  19. package/dist/default/InviteAcceptPage.js +74 -38
  20. package/dist/default/InviteAcceptPage.js.map +1 -1
  21. package/dist/default/MembersManager.d.ts +12 -4
  22. package/dist/default/MembersManager.d.ts.map +1 -1
  23. package/dist/default/MembersManager.js +320 -84
  24. package/dist/default/MembersManager.js.map +1 -1
  25. package/dist/default/RoleSelectField.d.ts +64 -0
  26. package/dist/default/RoleSelectField.d.ts.map +1 -0
  27. package/dist/default/RoleSelectField.js +78 -0
  28. package/dist/default/RoleSelectField.js.map +1 -0
  29. package/dist/default/WorkspaceSettings.d.ts +7 -1
  30. package/dist/default/WorkspaceSettings.d.ts.map +1 -1
  31. package/dist/default/WorkspaceSettings.js +203 -45
  32. package/dist/default/WorkspaceSettings.js.map +1 -1
  33. package/dist/default/WorkspacesPage.d.ts +13 -0
  34. package/dist/default/WorkspacesPage.d.ts.map +1 -0
  35. package/dist/default/WorkspacesPage.js +108 -0
  36. package/dist/default/WorkspacesPage.js.map +1 -0
  37. package/dist/default/index.d.ts +28 -7
  38. package/dist/default/index.d.ts.map +1 -1
  39. package/dist/default/index.js +24 -7
  40. package/dist/default/index.js.map +1 -1
  41. package/dist/default/parts.d.ts +104 -0
  42. package/dist/default/parts.d.ts.map +1 -0
  43. package/dist/default/parts.js +140 -0
  44. package/dist/default/parts.js.map +1 -0
  45. package/dist/headless/Members.d.ts +59 -14
  46. package/dist/headless/Members.d.ts.map +1 -1
  47. package/dist/headless/Members.js +35 -10
  48. package/dist/headless/Members.js.map +1 -1
  49. package/dist/headless/RoleSelect.d.ts +9 -3
  50. package/dist/headless/RoleSelect.d.ts.map +1 -1
  51. package/dist/headless/RoleSelect.js +3 -2
  52. package/dist/headless/RoleSelect.js.map +1 -1
  53. package/dist/i18n/es.d.ts +11 -12
  54. package/dist/i18n/es.d.ts.map +1 -1
  55. package/dist/i18n/es.js +267 -14
  56. package/dist/i18n/es.js.map +1 -1
  57. package/dist/i18n/generated/errors.es.gen.d.ts.map +1 -1
  58. package/dist/i18n/generated/errors.es.gen.js +5 -0
  59. package/dist/i18n/generated/errors.es.gen.js.map +1 -1
  60. package/dist/i18n/generated/errors.gen.d.ts +30 -0
  61. package/dist/i18n/generated/errors.gen.d.ts.map +1 -1
  62. package/dist/i18n/generated/errors.gen.js +15 -0
  63. package/dist/i18n/generated/errors.gen.js.map +1 -1
  64. package/dist/i18n/generated/errors.ru.gen.d.ts.map +1 -1
  65. package/dist/i18n/generated/errors.ru.gen.js +5 -0
  66. package/dist/i18n/generated/errors.ru.gen.js.map +1 -1
  67. package/dist/i18n/keys.d.ts +209 -1
  68. package/dist/i18n/keys.d.ts.map +1 -1
  69. package/dist/i18n/keys.js +410 -4
  70. package/dist/i18n/keys.js.map +1 -1
  71. package/dist/i18n/ru.d.ts +7 -2
  72. package/dist/i18n/ru.d.ts.map +1 -1
  73. package/dist/i18n/ru.js +209 -4
  74. package/dist/i18n/ru.js.map +1 -1
  75. package/dist/index.d.ts +2 -2
  76. package/dist/index.d.ts.map +1 -1
  77. package/dist/index.js +6 -2
  78. package/dist/index.js.map +1 -1
  79. package/dist/model/format.d.ts +56 -0
  80. package/dist/model/format.d.ts.map +1 -0
  81. package/dist/model/format.js +125 -0
  82. package/dist/model/format.js.map +1 -0
  83. package/dist/model/mandate.d.ts +23 -1
  84. package/dist/model/mandate.d.ts.map +1 -1
  85. package/dist/model/mandate.js +24 -0
  86. package/dist/model/mandate.js.map +1 -1
  87. package/dist/model/selection.d.ts +15 -0
  88. package/dist/model/selection.d.ts.map +1 -1
  89. package/dist/model/selection.js +18 -1
  90. package/dist/model/selection.js.map +1 -1
  91. package/dist/nav/manifest.d.ts +45 -0
  92. package/dist/nav/manifest.d.ts.map +1 -0
  93. package/dist/nav/manifest.js +77 -0
  94. package/dist/nav/manifest.js.map +1 -0
  95. package/llms.txt +11 -10
  96. package/manifest.json +282 -69
  97. package/package.json +18 -12
  98. package/src/analytics/generated/events.json +1 -1
  99. package/src/api/generated/schema.ts +187 -60
  100. package/src/default/ActiveWorkspace.tsx +97 -0
  101. package/src/default/AuditTrailPane.tsx +309 -0
  102. package/src/default/InvitationsPane.tsx +501 -0
  103. package/src/default/InviteAcceptPage.tsx +187 -90
  104. package/src/default/MembersManager.tsx +832 -222
  105. package/src/default/RoleSelectField.tsx +187 -0
  106. package/src/default/WorkspaceSettings.tsx +540 -125
  107. package/src/default/WorkspacesPage.tsx +387 -0
  108. package/src/default/index.ts +28 -7
  109. package/src/default/parts.tsx +308 -0
  110. package/src/headless/Members.tsx +84 -16
  111. package/src/headless/RoleSelect.tsx +12 -5
  112. package/src/i18n/es.ts +326 -14
  113. package/src/i18n/generated/errors.es.gen.ts +5 -0
  114. package/src/i18n/generated/errors.gen.ts +15 -0
  115. package/src/i18n/generated/errors.json +37 -0
  116. package/src/i18n/generated/errors.ru.gen.ts +5 -0
  117. package/src/i18n/keys.ts +462 -5
  118. package/src/i18n/ru.ts +259 -5
  119. package/src/index.ts +6 -1
  120. package/src/model/format.ts +158 -0
  121. package/src/model/mandate.ts +26 -1
  122. package/src/model/selection.tsx +19 -1
  123. package/src/nav/manifest.ts +120 -0
  124. package/dist/default/ErrorAlert.d.ts +0 -8
  125. package/dist/default/ErrorAlert.d.ts.map +0 -1
  126. package/dist/default/ErrorAlert.js +0 -30
  127. package/dist/default/ErrorAlert.js.map +0 -1
  128. package/src/default/ErrorAlert.tsx +0 -47
package/CHANGELOG.md CHANGED
@@ -1,5 +1,199 @@
1
1
  # @stapel/workspaces-react
2
2
 
3
+ ## 0.19.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 57bd738: Visual pass 3: delete the legacy harness stories, and stop the screens saying
8
+ the same thing twice.
9
+
10
+ **The seven harness demos are gone** (`workspace-list`, `members--default`,
11
+ `accept-invitation`, `capability-gate`, `invite-accept-flow`,
12
+ `role-select--registry`, `workspaces-provider`). They shipped a debug card —
13
+ `state.step` chips, `1 workspace(s)`, "Grant exchanged", `members.invite
14
+ allowed` — beside the real skins that replaced them, so half of what this
15
+ package showed was the harness. The headless primitives they stood for are now
16
+ covered by the skin demo that renders each one (`covers:`), and `demo/_harness`
17
+ is the provider frame and nothing else. **Breaking for anyone who deep-links a
18
+ viewer story id.**
19
+
20
+ Screen fixes, each one a repetition or a contradiction:
21
+
22
+ - **Invitations.** A terminal row printed its refusal once per control — the
23
+ same sentence six times on one phone screen. `RowActions` states it once per
24
+ row, as a footnote spanning the row, with every switched-off control's
25
+ `aria-describedby` pointing at it.
26
+ - **Workspaces page.** A failed read said so twice, in two wordings, with two
27
+ recoveries (the create button's gate carried its own copy plus `HTTP 503`
28
+ above the alert that already had the retry). One alert now, the control
29
+ points at it, and the retry sits under the alert instead of squeezing its
30
+ text into ~110px at 390px. The empty state no longer repeats the restriction
31
+ the disabled control states.
32
+ - **Workspace settings.** "Require two-factor authentication" read ON while the
33
+ line under it said two-factor was not required here — the note derived from
34
+ the absence of an enforcement status instead of from the policy. Both now
35
+ read the same value, and "no check has run yet" is its own sentence.
36
+ - **Invitation page.** A dead expired link was drawn in antd's _info_ blue with
37
+ nothing to do next; it is the warning tone, names the next step, and takes an
38
+ optional `onExit` for the host's way out.
39
+ - **Members.** The refusals on the viewer's own row widened the action column
40
+ until that row wrapped to the phone layout in the middle of a desktop table.
41
+ One row geometry per breakpoint now. The empty roster has one "Invite", not
42
+ two, and no search field over nobody.
43
+ - **Roles.** "It is not a workspace without roles." was not English. An empty
44
+ registry no longer borrows the "we could not load" sentence.
45
+ - **Membership history.** `Role: admin` was the raw slug; it goes through the
46
+ same registry labels as the picker. Events are grouped under their day, so a
47
+ burst of three no longer prints one timestamp three times.
48
+
49
+ Additive API: `InviteAcceptPageProps.onExit`, `WorkspaceFormat.time()`,
50
+ `useRoleLabel()` (a role label with no `GET /roles` behind it — the public
51
+ invitation page cannot make that call).
52
+
53
+ ## 0.18.0
54
+
55
+ ### Minor Changes
56
+
57
+ - 80617e9: The pair becomes the product: the delete button stops lying, the MFA policy gets a screen, and the invitation console exists
58
+
59
+ **The refusal a screen used to promise.** `<WorkspaceSettings/>` drew "Delete
60
+ workspace" from `my_role === "owner"` — the exact derivation stapel-workspaces
61
+ 0.26.0 added `WorkspaceResponse.can_delete` + `delete_blocked_reason` to
62
+ replace, because an owner of the instance's default workspace or of a personal
63
+ one gets a 409. The control now reads the server's verdict through
64
+ `GatedControl`, and prints the server's own refusal CODE as a sentence beside
65
+ it — translatable at last, because the generated error bundle was five codes
66
+ behind the backend (`error.409.workspace_is_instance_default`,
67
+ `workspace_is_personal`, `error.429.invitation_grant_pending`,
68
+ `error.503.billing_unavailable`, `error.503.mandate_unavailable`). The test
69
+ that cemented the old rule now asserts the new one. Name and security editing
70
+ are gated on the CAPABILITIES the server granted (`workspace.update`,
71
+ `workspace.security.manage`), falling back to the pre-0.6 owner rule only when
72
+ a backend sends no `my_capabilities` at all.
73
+
74
+ **Four screens that were hooks with no pixels.** `<WorkspacesPage/>` (the §54
75
+ default skin `WorkspaceList` never had: roster, create gated on
76
+ `can_create_workspace`, preferred-workspace choice, guest and closed-instance
77
+ copy), `<InvitationsPane/>` (list / revoke / resend / rename — four API methods
78
+ and zero pixels until now, each control gated on the invitation state the
79
+ endpoint would refuse), `<AuditTrailPane/>` (the membership history, action
80
+ vocabulary as sentences, timestamps as "3 days ago (23 Sept 2026, 09:00)"), and
81
+ `<RoleSelectField/>` (a real `<Select>` with an accessible name and the rank as
82
+ a caption, where the story used to show five bullet points).
83
+
84
+ **MFA enforcement, visible.** `mfa_enforcement` (state, coverage, unverified
85
+ members, last error) is drawn beside the `require_mfa` switch that
86
+ `useUpdateSecuritySettings` now actually has a consumer for, and
87
+ `MemberResponse.mfa_compliant` is a per-member tag with three states — true,
88
+ false, and "nobody has asked yet".
89
+
90
+ **Members reach page two.** `<Members>` takes `params` and exposes `page`
91
+ (anchor cursors + `has_next`/`has_prev`); the roster ships a pager and a
92
+ search box. `rosterComplete` now also requires `has_prev === false`, so the
93
+ last-owner claim is not made from the last page of a longer roster.
94
+
95
+ **Substrate migration.** Local `src/default/ErrorAlert.tsx` deleted; every
96
+ screen self-themes through `<SkinTheme>`, confirms are `SkinConfirm` (no
97
+ `Popconfirm`), load arms are `LoadList`/`LoadBoundary`, blocked controls are
98
+ `GatedButton`/`GatedControl`, and the unfilled `renderLoginPanel` slot renders
99
+ a `SlotPlaceholder` instead of a hole. Doctrine lint: 99 warnings → 0.
100
+
101
+ **i18n.** `es` went from 1 translated UI key to complete; `ru` stays complete
102
+ over a bundle that roughly tripled. Counts render through CLDR plurals
103
+ (`useTPlural`) instead of `1 workspace(s)`, and dates through a new
104
+ `useWorkspaceFormat()` in the model layer instead of raw ISO.
105
+
106
+ BREAKING (pre-1.0, hence minor): `MembersBag` gained `page`/`rename`/
107
+ `isRenaming`, `RoleSelect`'s `labelFor` now title-cases an unlabelled registry
108
+ role (`secretary` → `Secretary`) instead of returning the raw token, and
109
+ `src/default/ErrorAlert.tsx` is gone — import `ErrorAlert` from
110
+ `@stapel/tokens-antd/skin`.
111
+
112
+ - 308e3d6: `is_self` closes two lying controls, the four admin screens get doors, and every default-skin screen is finally photographed
113
+
114
+ **The row the roster could not identify.** stapel-workspaces 0.30.0 derives
115
+ `MemberResponse.is_self` server-side, and the pair now reads it off the
116
+ generated schema instead of a defensive cast. It gates TWO controls, not one.
117
+ "Remove" was already asking. "Reset password" was not — and it is the worse
118
+ case: `MemberPasswordResetView` refuses the caller's own row with the
119
+ byte-identical 404 it gives for a stranger ("Yourself is not in the set this
120
+ endpoint acts on"), so an ungated button reads the backend's correct refusal
121
+ as "this member has been removed". Both are switched off through `GatedButton`
122
+ with the reason beside them, in en/ru/es. A backend that sends no `is_self`
123
+ still claims nothing: the absence reads as "the server did not say".
124
+
125
+ **The administrative password reset now exists on the glass.**
126
+ `useResetMemberPassword` had no consumer at all — the endpoint (#110) was
127
+ unreachable from any shipped screen. `<MembersManager/>` grows the control and
128
+ the dialog around it, and the dialog states the three things this operation is:
129
+ the step-up is ANNOUNCED before the click (the capability is declared `high`,
130
+ so `requires_verification(scope="sensitive")` will demand one), the generated
131
+ password is labelled as the one-shot credential it is and leaves the screen
132
+ when the dialog closes (the mutation is reset), and `notified: false` is said
133
+ out loud — it means the account had no channel to be told on, which makes the
134
+ admin the only person who can tell them.
135
+
136
+ **Four finished screens stop being undeclared.** `<WorkspaceSettings/>`,
137
+ `<MembersManager/>`, `<InvitationsPane/>` and `<AuditTrailPane/>` are in the
138
+ nav manifest, on paths relative to the account section, and their `workspaceId`
139
+ prop became OPTIONAL. The architecture answer the pair was waiting for: the
140
+ active workspace is RUNTIME state — the same state the container writes when a
141
+ person switches — not a path param of a settings URL. So a nav-mounted screen
142
+ reads it from the selection seam (`useOptionalWorkspaceSelection`, new export)
143
+ and a screen with no active workspace renders a designed "choose a workspace"
144
+ (and "you are not in a workspace yet" for a person who belongs to none) rather
145
+ than a blank page or a throw from a provider a shell forgot to wire.
146
+
147
+ **The showcase stops showing the test bench.** Seven default-skin demos, one
148
+ per `/default` export, each with a phone variant and variants seeded at
149
+ distinct steps — the roster with the viewer's own row refused, the settings
150
+ screen an owner may not delete, the terminal invitations, the audit line
151
+ nobody performed, the role field with no registry to read, the invitation page
152
+ on the wrong account. `gen:demos` goes 0/7 → 7/7 skin covered. A render test
153
+ per new surface at 390 and 1024, in light and dark, plus the `is_self` and
154
+ chooser cases: 164 → 187 tests.
155
+
156
+ Peer floors raised to the substrate the pair actually imports:
157
+ `@stapel/core >=0.18.0 <1.0.0` (`useTPlural`, `STAPEL_UI_KEYS`) and
158
+ `@stapel/tokens-antd >=0.6.0` (the skin surface).
159
+
160
+ - 95e8eec: The invite dialog is a bottom sheet on a phone, and the members table stops
161
+ offering removals the backend will refuse.
162
+
163
+ `MembersManager`'s `Modal` renders through `@stapel/tokens-antd/skin`'s
164
+ `SkinDialog` — this package now declares `@stapel/tokens-antd` as an optional
165
+ peer, like every other antd-skinned pair. The table gained
166
+ `scroll={{ x: true }}`.
167
+
168
+ Remove was rendered identically on every row, including the last owner's, which
169
+ the backend enforces against. When the roster is COMPLETE (`has_next` is false
170
+ — now surfaced on the bag as `rosterComplete`) "exactly one row holds owner" is
171
+ a fact, so that row's Remove is disabled with the reason printed beside it. On a
172
+ paginated roster nothing is claimed: a count of a page is not a count of the
173
+ roster, and gating on it would refuse a removal the backend would allow.
174
+
175
+ The caller's OWN row is still not gated, and deliberately: `MemberResponse`
176
+ carries no `is_self`, and the pair has no caller identity to compare `user_id`
177
+ against (core's session exposes a status, the mandate axis a role — neither is
178
+ "who am I"). Guessing would grey out somebody else's row. The backend needs one
179
+ additive field: `is_self` on `MemberResponse`, or the caller's `user_id` on the
180
+ members page envelope.
181
+
182
+ When the role registry read fails, the per-row role `Select` no longer renders
183
+ enabled over an empty option list; the role still reads as text and the invite
184
+ dialog states the outage instead of offering a picker with nothing in it.
185
+
186
+ ## 0.17.0
187
+
188
+ ### Minor Changes
189
+
190
+ - ace82db: `useMandateSource()` — this module's derivation in the shape core's
191
+ `<MandateProvider>` takes. A screen reads the axis with `useMandate()` from
192
+ `@stapel/core` and no longer imports this package to do it, which is what
193
+ lets the same screen work on a public surface that has no workspace list at
194
+ all. `useMandateState()` and its behaviour are unchanged; the `@stapel/core`
195
+ floor rises to the release that carries the seam.
196
+
3
197
  ## 0.16.0
4
198
 
5
199
  ### Minor Changes
package/README.md CHANGED
@@ -51,6 +51,38 @@ per-module override — `clients={{ workspaces: runtime.client }}` — and nest
51
51
  providers (`StapelConfigProvider` + `QueryClientProvider` + `I18nProvider`)
52
52
  remain exported for bespoke composition.
53
53
 
54
+ ## The default skin (`/default`)
55
+
56
+ The pair ships the screens, not only the hooks (§83): seven antd surfaces
57
+ behind a separate entry point, so a host that brings its own visuals never
58
+ pulls `antd` into its bundle.
59
+
60
+ ```tsx
61
+ import {
62
+ WorkspacesPage, WorkspaceSettings, MembersManager,
63
+ InvitationsPane, AuditTrailPane, RoleSelectField, InviteAcceptPage,
64
+ } from "@stapel/workspaces-react/default";
65
+ ```
66
+
67
+ Each one self-themes (light/dark from the live document), is a bottom sheet on
68
+ a phone and a modal above it, and states every refusal the backend would give
69
+ instead of offering a control that leads to one.
70
+
71
+ **Which workspace a screen is about.** `WorkspaceSettings`, `MembersManager`,
72
+ `InvitationsPane` and `AuditTrailPane` take an OPTIONAL `workspaceId`. Pass it
73
+ and they use it. Omit it — which is how the nav manifest mounts them, because
74
+ the nav contract routes a screen and does not hand it an ambient scope — and
75
+ they read the ACTIVE workspace from the runtime selection
76
+ (`<WorkspaceSelectionProvider>`, the same seam `switchTo` writes). With no
77
+ active workspace they render a designed "choose a workspace" state; they never
78
+ render blank and never throw at a host that has not wired the provider.
79
+
80
+ ```tsx
81
+ <WorkspaceSelectionProvider urlWorkspaceId={params.get("workspace")}>
82
+ <MembersManager /> {/* the workspace comes from the selection */}
83
+ </WorkspaceSelectionProvider>
84
+ ```
85
+
54
86
  ## Layers
55
87
 
56
88
  ```
@@ -100,13 +132,13 @@ Backend error texts are generated from stapel-workspaces's
100
132
  pair's UI keys carry hand-written ru copy. Register your own bundle AFTER the
101
133
  pair's to override any key — registration order is override priority.
102
134
 
103
- ### Spanish locale (opt-in subpath — backend errors today, UI copy later)
135
+ ### Spanish locale (opt-in subpath)
104
136
 
105
- The `es` bundle ships as its own subpath on the same terms as `ru`, with one
106
- difference stated up front: **it translates the 67 backend error codes, not
107
- the pair's own UI copy.** `registerWorkspacesI18nEs` registers the en floor UNDER the
108
- Spanish texts, so a Spanish-speaking user reads Spanish error messages and
109
- English UI copy — never a raw key.
137
+ The `es` bundle ships as its own subpath on the same terms as `ru`, and it is
138
+ complete on both halves: the generated backend error texts AND hand-written
139
+ Spanish for every UI key the pair owns. `registerWorkspacesI18nEs` registers
140
+ the en floor UNDER the Spanish texts, so a key added tomorrow degrades to
141
+ English rather than to a raw key.
110
142
 
111
143
  ```tsx
112
144
  import { registerWorkspacesI18nEs } from "@stapel/workspaces-react/i18n/es";
@@ -119,8 +151,7 @@ await i18n.setLocale("es"); // live switch; untranslated UI keys read English
119
151
  Error texts are generated from stapel-workspaces's `translations/errors.es.json`
120
152
  catalog (`pnpm gen:errors`, drift-gated) and are complete over the error
121
153
  registry by construction. The coverage boundary is asserted in
122
- `test/i18nEs.test.ts`; when hand-written Spanish UI copy lands, it lands
123
- additively — this subpath and `workspacesI18nBundleEs` keep their names.
154
+ `test/i18nEs.test.ts`, which asserts parity with the en bundle key for key.
124
155
 
125
156
  ## Guardrails
126
157
 
@@ -7,26 +7,26 @@ export interface paths {
7
7
  cookie?: never;
8
8
  };
9
9
  /**
10
- * @description Overridable serializer seams for API views.
10
+ * @description Turns an unaskable plan ceiling into 503 for the whole view.
11
11
  *
12
- * Subclasses (or downstream projects) can swap the request/response
13
- * serializers without copying method bodies:
14
- *
15
- * class MyWorkspaceDetailView(WorkspaceDetailView):
16
- * response_serializer_class = MyWorkspaceResponseSerializer
12
+ * Mixed in rather than written as an ``except`` at each call site because
13
+ * the seam is consulted from views AND from inside ``services`` under the
14
+ * workspace lock, and the set of call sites grows: a view that gains a
15
+ * method later inherits the mapping instead of re-deriving it. The 402
16
+ * denials stay per-call-site — those carry a limit the screen renders.
17
17
  *
18
18
  * **Permissions:** `IsAuthenticated`
19
19
  */
20
20
  get: operations["workspaces_api_v1_retrieve"];
21
21
  put?: never;
22
22
  /**
23
- * @description Overridable serializer seams for API views.
24
- *
25
- * Subclasses (or downstream projects) can swap the request/response
26
- * serializers without copying method bodies:
23
+ * @description Turns an unaskable plan ceiling into 503 for the whole view.
27
24
  *
28
- * class MyWorkspaceDetailView(WorkspaceDetailView):
29
- * response_serializer_class = MyWorkspaceResponseSerializer
25
+ * Mixed in rather than written as an ``except`` at each call site because
26
+ * the seam is consulted from views AND from inside ``services`` under the
27
+ * workspace lock, and the set of call sites grows: a view that gains a
28
+ * method later inherits the mapping instead of re-deriving it. The 402
29
+ * denials stay per-call-site — those carry a limit the screen renders.
30
30
  *
31
31
  * **Permissions:** `IsAuthenticated`
32
32
  */
@@ -45,13 +45,21 @@ export interface paths {
45
45
  cookie?: never;
46
46
  };
47
47
  /**
48
- * @description Overridable serializer seams for API views.
48
+ * @description Overridable request/response serializer seam for an API view.
49
+ *
50
+ * Declare the classes as attributes, read them through the getters::
49
51
  *
50
- * Subclasses (or downstream projects) can swap the request/response
51
- * serializers without copying method bodies:
52
+ * class WalletView(SerializerSeamMixin, APIView):
53
+ * response_serializer_class = WalletResponseSerializer
52
54
  *
53
- * class MyWorkspaceDetailView(WorkspaceDetailView):
54
- * response_serializer_class = MyWorkspaceResponseSerializer
55
+ * def get(self, request):
56
+ * response_cls = self.get_response_serializer_class()
57
+ * return StapelResponse(response_cls(wallet_to_dto(...)))
58
+ *
59
+ * A host project swaps either direction by subclassing and setting the
60
+ * attribute, or overrides the getter for a per-request decision — no HTTP
61
+ * method body is ever copied. ``None`` means the direction carries no
62
+ * serializer (raw ``request.FILES``, an empty 204 body).
55
63
  *
56
64
  * **Permissions:** `IsAuthenticated`
57
65
  */
@@ -59,13 +67,13 @@ export interface paths {
59
67
  put?: never;
60
68
  post?: never;
61
69
  /**
62
- * @description Overridable serializer seams for API views.
63
- *
64
- * Subclasses (or downstream projects) can swap the request/response
65
- * serializers without copying method bodies:
70
+ * @description End a workspace — the transition itself lives in the service.
66
71
  *
67
- * class MyWorkspaceDetailView(WorkspaceDetailView):
68
- * response_serializer_class = MyWorkspaceResponseSerializer
72
+ * The view's whole job here is authority (owner, and only owner) and
73
+ * turning the service's typed refusal back into the keyed 409 the
74
+ * frontend routes on. The write, the event and the audit line are one
75
+ * indivisible act in ``services.delete_workspace``, because a second
76
+ * door into this transition is a second chance to forget the record.
69
77
  *
70
78
  * **Permissions:** `IsAuthenticated`
71
79
  */
@@ -73,13 +81,21 @@ export interface paths {
73
81
  options?: never;
74
82
  head?: never;
75
83
  /**
76
- * @description Overridable serializer seams for API views.
84
+ * @description Overridable request/response serializer seam for an API view.
77
85
  *
78
- * Subclasses (or downstream projects) can swap the request/response
79
- * serializers without copying method bodies:
86
+ * Declare the classes as attributes, read them through the getters::
80
87
  *
81
- * class MyWorkspaceDetailView(WorkspaceDetailView):
82
- * response_serializer_class = MyWorkspaceResponseSerializer
88
+ * class WalletView(SerializerSeamMixin, APIView):
89
+ * response_serializer_class = WalletResponseSerializer
90
+ *
91
+ * def get(self, request):
92
+ * response_cls = self.get_response_serializer_class()
93
+ * return StapelResponse(response_cls(wallet_to_dto(...)))
94
+ *
95
+ * A host project swaps either direction by subclassing and setting the
96
+ * attribute, or overrides the getter for a per-request decision — no HTTP
97
+ * method body is ever copied. ``None`` means the direction carries no
98
+ * serializer (raw ``request.FILES``, an empty 204 body).
83
99
  *
84
100
  * **Permissions:** `IsAuthenticated`
85
101
  */
@@ -289,13 +305,21 @@ export interface paths {
289
305
  cookie?: never;
290
306
  };
291
307
  /**
292
- * @description Overridable serializer seams for API views.
308
+ * @description Overridable request/response serializer seam for an API view.
309
+ *
310
+ * Declare the classes as attributes, read them through the getters::
293
311
  *
294
- * Subclasses (or downstream projects) can swap the request/response
295
- * serializers without copying method bodies:
312
+ * class WalletView(SerializerSeamMixin, APIView):
313
+ * response_serializer_class = WalletResponseSerializer
296
314
  *
297
- * class MyWorkspaceDetailView(WorkspaceDetailView):
298
- * response_serializer_class = MyWorkspaceResponseSerializer
315
+ * def get(self, request):
316
+ * response_cls = self.get_response_serializer_class()
317
+ * return StapelResponse(response_cls(wallet_to_dto(...)))
318
+ *
319
+ * A host project swaps either direction by subclassing and setting the
320
+ * attribute, or overrides the getter for a per-request decision — no HTTP
321
+ * method body is ever copied. ``None`` means the direction carries no
322
+ * serializer (raw ``request.FILES``, an empty 204 body).
299
323
  *
300
324
  * **Permissions:** `IsAuthenticated`
301
325
  */
@@ -319,13 +343,21 @@ export interface paths {
319
343
  put?: never;
320
344
  post?: never;
321
345
  /**
322
- * @description Overridable serializer seams for API views.
346
+ * @description Overridable request/response serializer seam for an API view.
347
+ *
348
+ * Declare the classes as attributes, read them through the getters::
323
349
  *
324
- * Subclasses (or downstream projects) can swap the request/response
325
- * serializers without copying method bodies:
350
+ * class WalletView(SerializerSeamMixin, APIView):
351
+ * response_serializer_class = WalletResponseSerializer
326
352
  *
327
- * class MyWorkspaceDetailView(WorkspaceDetailView):
328
- * response_serializer_class = MyWorkspaceResponseSerializer
353
+ * def get(self, request):
354
+ * response_cls = self.get_response_serializer_class()
355
+ * return StapelResponse(response_cls(wallet_to_dto(...)))
356
+ *
357
+ * A host project swaps either direction by subclassing and setting the
358
+ * attribute, or overrides the getter for a per-request decision — no HTTP
359
+ * method body is ever copied. ``None`` means the direction carries no
360
+ * serializer (raw ``request.FILES``, an empty 204 body).
329
361
  *
330
362
  * **Permissions:** `IsAuthenticated`
331
363
  */
@@ -333,13 +365,21 @@ export interface paths {
333
365
  options?: never;
334
366
  head?: never;
335
367
  /**
336
- * @description Overridable serializer seams for API views.
368
+ * @description Overridable request/response serializer seam for an API view.
369
+ *
370
+ * Declare the classes as attributes, read them through the getters::
371
+ *
372
+ * class WalletView(SerializerSeamMixin, APIView):
373
+ * response_serializer_class = WalletResponseSerializer
337
374
  *
338
- * Subclasses (or downstream projects) can swap the request/response
339
- * serializers without copying method bodies:
375
+ * def get(self, request):
376
+ * response_cls = self.get_response_serializer_class()
377
+ * return StapelResponse(response_cls(wallet_to_dto(...)))
340
378
  *
341
- * class MyWorkspaceDetailView(WorkspaceDetailView):
342
- * response_serializer_class = MyWorkspaceResponseSerializer
379
+ * A host project swaps either direction by subclassing and setting the
380
+ * attribute, or overrides the getter for a per-request decision — no HTTP
381
+ * method body is ever copied. ``None`` means the direction carries no
382
+ * serializer (raw ``request.FILES``, an empty 204 body).
343
383
  *
344
384
  * **Permissions:** `IsAuthenticated`
345
385
  */
@@ -477,13 +517,13 @@ export interface paths {
477
517
  get?: never;
478
518
  put?: never;
479
519
  /**
480
- * @description Overridable serializer seams for API views.
520
+ * @description Turns an unaskable plan ceiling into 503 for the whole view.
481
521
  *
482
- * Subclasses (or downstream projects) can swap the request/response
483
- * serializers without copying method bodies:
484
- *
485
- * class MyWorkspaceDetailView(WorkspaceDetailView):
486
- * response_serializer_class = MyWorkspaceResponseSerializer
522
+ * Mixed in rather than written as an ``except`` at each call site because
523
+ * the seam is consulted from views AND from inside ``services`` under the
524
+ * workspace lock, and the set of call sites grows: a view that gains a
525
+ * method later inherits the mapping instead of re-deriving it. The 402
526
+ * denials stay per-call-site — those carry a limit the screen renders.
487
527
  *
488
528
  * **Permissions:** `IsAuthenticated`
489
529
  */
@@ -517,7 +557,8 @@ export interface paths {
517
557
  * Gate stack, in order: HIGH step-up (``@requires_verification``, scope
518
558
  * ``sensitive`` — the same store as admin step-up) → capability
519
559
  * ``members.provision`` (403) → entitlement ``workspaces.provision_user``
520
- * (402; degrades to allow without billing) → optional per-user debit
560
+ * (402; an unreachable billing answers 503, not allow) → optional
561
+ * per-user debit
521
562
  * (``STAPEL_WORKSPACES["PROVISION_USER_CREDITS"]`` > 0).
522
563
  *
523
564
  * Credentials: a synthetic account normally has no email — when the
@@ -650,6 +691,11 @@ export interface paths {
650
691
  * NOT consumed here: accept stays a separate, deliberate step after
651
692
  * setup. Neither the invite token nor the grant token is ever logged.
652
693
  *
694
+ * ONE live grant at a time (WORK-03): while the previous grant is inside
695
+ * its TTL this answers 429 ``error.429.invitation_grant_pending`` with a
696
+ * ``Retry-After``, so a leaked invite link cannot be replayed into an
697
+ * endless supply of sign-in credentials for the invited mailbox.
698
+ *
653
699
  * **Permissions:** `AllowAny`
654
700
  */
655
701
  post: operations["workspaces_api_v1_invitations_claim_create"];
@@ -671,11 +717,36 @@ export interface paths {
671
717
  /**
672
718
  * @description Decline an invitation — the invitee's terminal "no" (spec §B2).
673
719
  *
674
- * Authenticated + email-match, exactly like accept: only the invited
675
- * account may resolve the invitation, in either direction. Decline ≠
676
- * revoke — both states stay distinguishable in the preview ``status``.
720
+ * AllowAny, on the token, exactly like preview and claim — and this is a
721
+ * CHANGE from the original authenticated-only rule, made because that rule
722
+ * made the common "no" impossible to say.
723
+ *
724
+ * Saying no used to require an account. The invitee who has none is the
725
+ * majority case on this path (that is why ``claim`` exists at all), so
726
+ * declining meant first creating the very account the person was declining
727
+ * to create: the flow asked for the commitment before the decision, and
728
+ * the refusal left a live, empty account behind it. Verified on the stand.
729
+ *
730
+ * The token is the proof, and it is the same proof the module already
731
+ * accepts elsewhere for strictly more: ``claim`` takes this token from an
732
+ * anonymous caller and mints a LOGIN GRANT for the invited mailbox.
733
+ * Anything a token holder could do by declining, they could already do —
734
+ * and more — by claiming. What the token cannot do is act as somebody
735
+ * else: a request that arrives WITH a session naming an address must still
736
+ * be the invited address, so a signed-in person cannot resolve an
737
+ * invitation belonging to someone else. That check is unchanged.
738
+ *
739
+ * A session with no address at all (a guest session — this product mints
740
+ * them at meeting doors) is treated as the anonymous case rather than as a
741
+ * mismatched account: it names nobody, so it can neither prove nor
742
+ * disprove ownership, and the token stands on its own. Before this it was
743
+ * refused outright by ``ANONYMOUS_DENIED``, which is how a guest who was
744
+ * also an invitee could not decline at all.
745
+ *
746
+ * Decline ≠ revoke — both states stay distinguishable in the preview
747
+ * ``status``. Throttled and token-never-logged like its AllowAny siblings.
677
748
  *
678
- * **Permissions:** `IsAuthenticated`
749
+ * **Permissions:** `AllowAny`
679
750
  */
680
751
  post: operations["workspaces_api_v1_invitations_decline_create"];
681
752
  delete?: never;
@@ -694,13 +765,13 @@ export interface paths {
694
765
  get?: never;
695
766
  put?: never;
696
767
  /**
697
- * @description Overridable serializer seams for API views.
698
- *
699
- * Subclasses (or downstream projects) can swap the request/response
700
- * serializers without copying method bodies:
768
+ * @description Turns an unaskable plan ceiling into 503 for the whole view.
701
769
  *
702
- * class MyWorkspaceDetailView(WorkspaceDetailView):
703
- * response_serializer_class = MyWorkspaceResponseSerializer
770
+ * Mixed in rather than written as an ``except`` at each call site because
771
+ * the seam is consulted from views AND from inside ``services`` under the
772
+ * workspace lock, and the set of call sites grows: a view that gains a
773
+ * method later inherits the mapping instead of re-deriving it. The 402
774
+ * denials stay per-call-site — those carry a limit the screen renders.
704
775
  *
705
776
  * **Permissions:** `IsAuthenticated`
706
777
  */
@@ -996,6 +1067,46 @@ export interface components {
996
1067
  */
997
1068
  last_sent_at?: string | null;
998
1069
  };
1070
+ /** @description How far the workspace's require_mfa policy has actually got. */
1071
+ MFAEnforcementStatus: {
1072
+ /**
1073
+ * @description pending / enforcing / enforced / failed. Only "enforced" means every active member's second factor has been confirmed — a settings flag saying require_mfa=true says somebody asked for it, not that it holds
1074
+ * @example enforcing
1075
+ */
1076
+ state: string;
1077
+ /**
1078
+ * @description How many sweeps have run since the policy was switched on
1079
+ * @example 3
1080
+ */
1081
+ attempts?: number;
1082
+ /**
1083
+ * @description Members auth answered about in the last sweep
1084
+ * @example 12
1085
+ */
1086
+ checked_members?: number;
1087
+ /**
1088
+ * @description Of those, members with no strong factor — suspended with reason no_mfa
1089
+ * @example 2
1090
+ */
1091
+ noncompliant_members?: number;
1092
+ /**
1093
+ * @description Active members whose factor nobody has confirmed yet. They are NOT admitted while the policy is on; this is the number an administrator has to get to zero
1094
+ * @example 1
1095
+ */
1096
+ unverified_members?: number;
1097
+ /**
1098
+ * @description ISO 8601 time of the last sweep; null before the first
1099
+ * @example 2026-08-11T10:00:00Z
1100
+ */
1101
+ last_attempt_at?: string | null;
1102
+ /** @description ISO 8601 time coverage last became complete; null while it is not */
1103
+ completed_at?: string | null;
1104
+ /**
1105
+ * @description What auth answered when the last sweep failed, verbatim; empty otherwise. Never credential material
1106
+ * @example FunctionCallError: mfa_status timed out
1107
+ */
1108
+ last_error?: string;
1109
+ };
999
1110
  /** @description Invite payload. */
1000
1111
  MemberInviteRequest: {
1001
1112
  /**
@@ -1106,6 +1217,13 @@ export interface components {
1106
1217
  * @example Ada Lovelace
1107
1218
  */
1108
1219
  display_name?: string | null;
1220
+ /**
1221
+ * @description Whether this member was PROVEN to hold a strong second factor — true, false, or null for "nobody has asked yet". Under a require_mfa policy the null is what an administrator acts on: those members are not admitted until the answer arrives
1222
+ * @example true
1223
+ */
1224
+ mfa_compliant?: boolean | null;
1225
+ /** @description Whether this row IS the caller. Server-derived — the client is never asked to compare ids it may not have. Always false on the service-to-service membership read, where the caller is a service and not a member of anything */
1226
+ is_self?: boolean;
1109
1227
  };
1110
1228
  PaginatedAuditEventResponseList: {
1111
1229
  items: components["schemas"]["AuditEventResponse"][];
@@ -1393,6 +1511,15 @@ export interface components {
1393
1511
  * @example Victor P.
1394
1512
  */
1395
1513
  owner_display_name?: string;
1514
+ /** @description State of the security.require_mfa policy — present on the single-workspace responses (GET/PATCH detail) when the policy is on, null otherwise. The settings block says what was asked for; this says what actually holds, so an administrator who switched MFA on is not told "done" while half the organization was never checked */
1515
+ mfa_enforcement?: components["schemas"]["MFAEnforcementStatus"] | null;
1516
+ /** @description Whether THIS caller may delete THIS workspace right now. The ANSWER, not the inputs: a client deriving it from my_role alone draws a delete button on the instance's default workspace and on a personal workspace the next sign-in re-mints — both of which the endpoint refuses, which is the "button that leads to a refusal" defect. Same shape and same reason as can_create_workspace on the list response */
1517
+ can_delete?: boolean;
1518
+ /**
1519
+ * @description The error code the DELETE would return, or "" when can_delete is true. The CODE rather than a sentence, so the client renders the refusal in the caller's own language through the error map it already ships. Server verdict and screen explanation are ONE evaluation (services.deletion_block_reason), so a screen cannot promise what the endpoint denies
1520
+ * @example error.409.workspace_is_instance_default
1521
+ */
1522
+ delete_blocked_reason?: string;
1396
1523
  };
1397
1524
  };
1398
1525
  responses: never;