Name exactly one gate on every declaration, and own a shift by identity id
This commit is contained in:
@@ -182,12 +182,16 @@ Revisit only if the stated reason stops holding.
|
|||||||
example it keeps the route table and the in-handler guard deriving from one function, so 29 routes
|
example it keeps the route table and the in-handler guard deriving from one function, so 29 routes
|
||||||
× 2 gate sites cannot drift. Generalised, it would make authorization a function of the transport
|
× 2 gate sites cannot drift. Generalised, it would make authorization a function of the transport
|
||||||
verb — a route table must answer "what does this need?" on its own.
|
verb — a route table must answer "what does this need?" on its own.
|
||||||
- **A gate is one of three, and `session` is a first-class one.** A route or nav node names exactly
|
- **A gate is one of three, named exactly once, and `session` is a first-class one.** A route or nav
|
||||||
one of `public`, `session`, `permission` (discovery refuses two), and `src/auth/gate.ts` is the one
|
node names exactly one of `public`, `session`, `permission` — discovery refuses none, two, and a
|
||||||
home of the rule the router and the menu both read. `session` exists because a plugin whose data is
|
flag spelled anything but `true`, so a forgotten gate fails the boot rather than publishing a page.
|
||||||
|
`src/auth/gate.ts` is the one home of the rule the plugin router, the host's own route table and
|
||||||
|
the menu all read. `session` exists because a plugin whose data is
|
||||||
the visitor's own — their upstream account, their own tokens — has no distinction a permission could
|
the visitor's own — their upstream account, their own tokens — has no distinction a permission could
|
||||||
name; the alternative, granting every newly registered user a permission, couples the identity
|
name; the alternative, granting every newly registered user a permission, couples the identity
|
||||||
lifecycle to a Keto write that nothing retries when it fails.
|
lifecycle to a Keto write that nothing retries when it fails. A page scoped to "mine" joins on
|
||||||
|
`ctx.user.id`, never the email — an address is user-changeable and can be reassigned to someone
|
||||||
|
who would then inherit the previous holder's rows.
|
||||||
- **The reference plugin's two shift pages duplicate a view model and markup on purpose.** An example
|
- **The reference plugin's two shift pages duplicate a view model and markup on purpose.** An example
|
||||||
is read far more often than it is changed, and each page reads top to bottom on its own. **Valid
|
is read far more often than it is changed, and each page reads top to bottom on its own. **Valid
|
||||||
while `examples/plugins/scheduling` stays a teaching artifact rather than a maintained product.**
|
while `examples/plugins/scheduling` stays a teaching artifact rather than a maintained product.**
|
||||||
|
|||||||
@@ -352,7 +352,7 @@ import { listThings, createThings } from "./handlers.ts";
|
|||||||
export default definePlugin({
|
export default definePlugin({
|
||||||
apiVersion: "0.4.0", // semver string of the host contract this plugin was built against (see Versioning)
|
apiVersion: "0.4.0", // semver string of the host contract this plugin was built against (see Versioning)
|
||||||
|
|
||||||
// Nav fragment, merged into the global menu and permission-filtered per user.
|
// Nav fragment, merged into the global menu and gate-filtered per user.
|
||||||
// `icon` is a Lucide icon by its sprite id (src/ui/icons.ts).
|
// `icon` is a Lucide icon by its sprite id (src/ui/icons.ts).
|
||||||
nav: [{ href: "/things", icon: "i-cal", id: "things:list", label: "Things", permission: "things:read" }],
|
nav: [{ href: "/things", icon: "i-cal", id: "things:list", label: "Things", permission: "things:read" }],
|
||||||
|
|
||||||
@@ -362,7 +362,7 @@ export default definePlugin({
|
|||||||
{ description: "Create and edit things", name: "things:write" },
|
{ description: "Create and edit things", name: "things:write" },
|
||||||
],
|
],
|
||||||
|
|
||||||
// Route handlers, mounted under the plugin's path (/things). `permission` gates first.
|
// Route handlers, mounted under the plugin's path (/things). The gate runs first.
|
||||||
routes: [
|
routes: [
|
||||||
{ method: "GET", path: "/", permission: "things:read", handler: listThings },
|
{ method: "GET", path: "/", permission: "things:read", handler: listThings },
|
||||||
{ method: "POST", path: "/", permission: "things:write", handler: createThings },
|
{ method: "POST", path: "/", permission: "things:write", handler: createThings },
|
||||||
@@ -379,7 +379,7 @@ folder-derived `id` to produce the loaded `Plugin`.
|
|||||||
| `apiVersion` | yes | Semver string of the host contract the plugin was built against. See [Versioning](#contract-versioning). |
|
| `apiVersion` | yes | Semver string of the host contract the plugin was built against. See [Versioning](#contract-versioning). |
|
||||||
| `home` | no | A `RouteHandler` that owns the **public** landing `/`. At most one plugin may declare it. See [The landing pages](#the-landing-pages-home--dashboard). |
|
| `home` | no | A `RouteHandler` that owns the **public** landing `/`. At most one plugin may declare it. See [The landing pages](#the-landing-pages-home--dashboard). |
|
||||||
| `dashboard` | no | A `RouteHandler` that owns the **gated** app home `/dashboard`. At most one plugin may declare it. See [The landing pages](#the-landing-pages-home--dashboard). |
|
| `dashboard` | no | A `RouteHandler` that owns the **gated** app home `/dashboard`. At most one plugin may declare it. See [The landing pages](#the-landing-pages-home--dashboard). |
|
||||||
| `nav` | no | `NavNode[]` fragment (same shape `composeNav` consumes). `icon` is a Lucide sprite id (`src/ui/icons.ts`); node `id`s must be globally unique. A `label` that names a catalog key is [translated](#languages-i18n); anything else renders as written. |
|
| `nav` | no | `NavNode[]` fragment (same shape `composeNav` consumes). Every node names [exactly one gate](#public-pages--menu-items). `icon` is a Lucide sprite id (`src/ui/icons.ts`); node `id`s must be globally unique. A `label` that names a catalog key is [translated](#languages-i18n); anything else renders as written. |
|
||||||
| `permissions` | no | Permissions this plugin gates on. See [Nav & permission gates](#nav--permission-gates). |
|
| `permissions` | no | Permissions this plugin gates on. See [Nav & permission gates](#nav--permission-gates). |
|
||||||
| `routes` | no | See [Routes & handlers](#routes--handlers). |
|
| `routes` | no | See [Routes & handlers](#routes--handlers). |
|
||||||
| `hooks` | no | See [Hooks](#hooks). |
|
| `hooks` | no | See [Hooks](#hooks). |
|
||||||
@@ -390,14 +390,13 @@ A plugin may be routes-only, nav-only, or hooks-only — every collection field
|
|||||||
|
|
||||||
### Routes & handlers
|
### Routes & handlers
|
||||||
|
|
||||||
A route is `{ method, path, permission?, public?, session?, handler }`. `path` is **relative to the plugin's
|
A route is `{ method, path, handler }` plus [exactly one gate](#public-pages--menu-items) —
|
||||||
|
`permission`, `public: true` or `session: true`. `path` is **relative to the plugin's
|
||||||
mount path `/<id>`** (so `path: "/:id"` in the `things` plugin serves `/things/:id`); the host matches
|
mount path `/<id>`** (so `path: "/:id"` in the `things` plugin serves `/things/:id`); the host matches
|
||||||
`method` + the resolved full path, extracts `:name` segments into `ctx.params.name`, runs the
|
`method` + the resolved full path, extracts `:name` segments into `ctx.params.name`, runs the
|
||||||
`permission` gate ([a coarse JWT-claim check](#nav--permission-gates)), then calls the handler with
|
gate ([a coarse JWT-claim check](#nav--permission-gates)), then calls the handler with
|
||||||
the [request context](#requestcontext). A failed gate redirects an **anonymous** visitor to `/login`
|
the [request context](#requestcontext). A failed gate redirects an **anonymous** visitor to `/login`
|
||||||
with the page as `return_to`; a **signed-in** user lacking the permission gets the **403** page.
|
with the page as `return_to`; a **signed-in** user lacking the permission gets the **403** page.
|
||||||
`public: true` means no gate at all, `session: true` any signed-in user (see
|
|
||||||
[Public pages](#public-pages--menu-items)).
|
|
||||||
|
|
||||||
`method` is one of `GET HEAD POST PUT PATCH DELETE`. A `GET` route also answers `HEAD`.
|
`method` is one of `GET HEAD POST PUT PATCH DELETE`. A `GET` route also answers `HEAD`.
|
||||||
|
|
||||||
@@ -571,27 +570,28 @@ system plugins you author or vendor. An ordinary domain plugin ignores it.
|
|||||||
|
|
||||||
A plugin's `nav` fragment is merged into the global menu by `composeNav` (`src/ui/nav.ts`), which
|
A plugin's `nav` fragment is merged into the global menu by `composeNav` (`src/ui/nav.ts`), which
|
||||||
applies the central override and then **filters per user** by the permissions in the session JWT: a
|
applies the central override and then **filters per user** by the permissions in the session JWT: a
|
||||||
node shows iff it is `public`, is `session` and someone is signed in, declares no `permission`, or
|
node shows iff it is `public`, is `session` and someone is signed in, or names a `permission` the
|
||||||
the user holds that name. A node's `icon` is a **Lucide icon** by sprite id (e.g. `i-cal` → lucide `calendar`); the available ids are
|
user holds. A node's `icon` is a **Lucide icon** by sprite id (e.g. `i-cal` → lucide `calendar`); the
|
||||||
`ICON_NAMES` in `src/ui/icons.ts`, and adding one means registering its lucide name there.
|
available ids are `ICON_NAMES` in `src/ui/icons.ts`, and adding one means registering its lucide name
|
||||||
|
there.
|
||||||
|
|
||||||
**Gating a section header.** A `permission` on the header takes the whole subtree with it. When the
|
**Gating a section header.** A `permission` on the header takes the whole subtree with it. When the
|
||||||
children need *different* permissions, leave the header ungated and gate each child — `composeNav`
|
children need *different* permissions, mark the header `public: true` — it then gates nothing, each
|
||||||
drops a header whose children all filtered out. That only works while the header carries **no
|
child decides, and `composeNav` drops a header whose children all filtered out. That only works while
|
||||||
`href`**: give it one and it survives as an ungated leaf, visible to everyone.
|
the header carries **no `href`**: give it one and it survives as a leaf, visible to everyone.
|
||||||
|
|
||||||
#### Public pages & menu items
|
#### Public pages & menu items
|
||||||
|
|
||||||
A route or nav node marked **`public: true`** is reachable by anyone and shows in everyone's menu.
|
A route or nav node marked **`public: true`** is reachable by anyone and shows in everyone's menu —
|
||||||
That is the same as omitting `permission`, but stated outright so public is a deliberate choice
|
open stated outright, so it is a deliberate choice rather than a forgotten gate.
|
||||||
rather than a forgotten gate.
|
|
||||||
|
|
||||||
**`session: true`** takes any signed-in user, with no grant to hold — for a plugin whose data is the
|
**`session: true`** takes any signed-in user, with no grant to hold — for a plugin whose data is the
|
||||||
visitor's own. An anonymous visitor is bounced to `/login` with the page as `return_to`, exactly as a
|
visitor's own. An anonymous visitor is bounced to `/login` with the page as `return_to`, exactly as a
|
||||||
permission gate does.
|
permission gate does.
|
||||||
|
|
||||||
A declaration names **exactly one** of the three; two of them contradict, and discovery refuses the
|
Every route and nav node names **exactly one** of the three, spelled `true` (or a permission name).
|
||||||
plugin at boot.
|
Naming none, naming two, or spelling one `false` is refused at boot — so a forgotten gate fails the
|
||||||
|
plugin instead of publishing a page.
|
||||||
|
|
||||||
A public page still renders in the native shell; for an anonymous visitor `ctx.user` is `null`, the
|
A public page still renders in the native shell; for an anonymous visitor `ctx.user` is `null`, the
|
||||||
shell shows a **Sign in** link in place of the profile block, the gated **Dashboard** link is hidden,
|
shell shows a **Sign in** link in place of the profile block, the gated **Dashboard** link is hidden,
|
||||||
@@ -644,7 +644,7 @@ The host detects collisions across all discovered plugins with `findConflicts` a
|
|||||||
Mount-path uniqueness needs no rule of its own — it follows from the id check. Discovery also
|
Mount-path uniqueness needs no rule of its own — it follows from the id check. Discovery also
|
||||||
rejects **per-manifest shape errors**: a non-array `nav`/`routes`/`permissions`, a non-function
|
rejects **per-manifest shape errors**: a non-array `nav`/`routes`/`permissions`, a non-function
|
||||||
`home`/`dashboard`, a permission name that isn't [`<resource>:<action>`](#naming-a-permission), or a
|
`home`/`dashboard`, a permission name that isn't [`<resource>:<action>`](#naming-a-permission), or a
|
||||||
route/nav node naming more than one gate.
|
route/nav node that does not name [exactly one gate](#public-pages--menu-items).
|
||||||
|
|
||||||
### Hooks
|
### Hooks
|
||||||
|
|
||||||
@@ -920,10 +920,9 @@ The menu is **driven entirely by config** and assembled from two sources:
|
|||||||
export default defineMenu({ branding: { name: "Acme Ops" }, override: { hide: ["teams"] } });
|
export default defineMenu({ branding: { name: "Acme Ops" }, override: { hide: ["teams"] } });
|
||||||
```
|
```
|
||||||
|
|
||||||
Every nav item may carry a `permission`; the rendered tree is **filtered per user** from the session
|
Every nav item names one gate — a `permission`, **`public: true`** (everyone) or **`session: true`**
|
||||||
JWT (no per-request authz call), so the menu only shows what that person can reach. An item may
|
(anyone signed in); the rendered tree is **filtered per user** from the session JWT (no per-request
|
||||||
instead be **`public: true`** (everyone) or **`session: true`** (anyone signed in) — one gate per
|
authz call), so the menu only shows what that person can reach.
|
||||||
item, never two.
|
|
||||||
Branding (name, logo, default theme) renders in the app shell.
|
Branding (name, logo, default theme) renders in the app shell.
|
||||||
|
|
||||||
**One menu, one shell, everywhere.** A single menu (`src/ui/chrome.ts` `buildPluginChrome`) renders
|
**One menu, one shell, everywhere.** A single menu (`src/ui/chrome.ts` `buildPluginChrome`) renders
|
||||||
|
|||||||
@@ -194,11 +194,13 @@ test.describe.serial("authenticated admin journey", () => {
|
|||||||
await expect(page.locator("h1")).toHaveText("Shifts");
|
await expect(page.locator("h1")).toHaveText("Shifts");
|
||||||
await expect(page.locator("table")).toContainText("Morning — Front desk"); // seeded by the mock upstream
|
await expect(page.locator("table")).toContainText("Morning — Front desk"); // seeded by the mock upstream
|
||||||
|
|
||||||
// The session-gated page asks the upstream for this visitor's rows: the one seeded against the
|
// The session-gated page scopes the upstream read by the visitor's identity id. The demo
|
||||||
// signed-in admin is there, and another person's shift is not.
|
// upstream's rows belong to three made-up people, so the admin's own page is empty — which is
|
||||||
|
// the assertion that matters: nobody else's shifts come back. (A matching row rendering is
|
||||||
|
// covered where it is exact, in the plugin's own unit test.)
|
||||||
await page.goto("/scheduling/mine");
|
await page.goto("/scheduling/mine");
|
||||||
await expect(page.locator("h1")).toHaveText("My shifts");
|
await expect(page.locator("h1")).toHaveText("My shifts");
|
||||||
await expect(page.locator("table")).toContainText("Night — Escalations");
|
await expect(page.getByText("No shifts are assigned to admin@plainpages.local")).toBeVisible();
|
||||||
await expect(page.locator("table")).not.toContainText("Morning — Front desk");
|
await expect(page.locator("table")).not.toContainText("Morning — Front desk");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export const ADMIN_NAV: NavNode = {
|
|||||||
icon: "i-shield",
|
icon: "i-shield",
|
||||||
id: "admin",
|
id: "admin",
|
||||||
label: "admin.nav.section", // a key in this plugin's catalog; the host translates nav labels
|
label: "admin.nav.section", // a key in this plugin's catalog; the host translates nav labels
|
||||||
|
public: true, // the header gates nothing; every child needs a permission, and an empty header is dropped
|
||||||
};
|
};
|
||||||
|
|
||||||
// The screen gate: a signed-in user holding this request's `<resource>:<action>`. Each route already
|
// The screen gate: a signed-in user holding this request's `<resource>:<action>`. Each route already
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ What it demonstrates:
|
|||||||
- **All three route gates** — the Overview is `public` (anyone), "My shifts" is `session` (any
|
- **All three route gates** — the Overview is `public` (anyone), "My shifts" is `session` (any
|
||||||
signed-in visitor, showing only rows assigned to them), and "Shifts" is gated on `scheduling:read` /
|
signed-in visitor, showing only rows assigned to them), and "Shifts" is gated on `scheduling:read` /
|
||||||
`scheduling:write`; a leaf whose gate a visitor fails is invisible in the menu.
|
`scheduling:write`; a leaf whose gate a visitor fails is invisible in the menu.
|
||||||
|
- **Ownership joined on the identity id** — "My shifts" asks the upstream for `assigneeId=ctx.user.id`,
|
||||||
|
the opaque subject id, and renders the row's separate `assignee` display name. An email address is
|
||||||
|
user-changeable and can be reassigned to someone else, who would then inherit those rows.
|
||||||
- **Its own translations** — every string comes from `i18n/en-US.ts` (`sv-SE.ts` beside it), including
|
- **Its own translations** — every string comes from `i18n/en-US.ts` (`sv-SE.ts` beside it), including
|
||||||
the nav labels, which are catalog keys in the manifest. `shifts.count` shows a plural message, and
|
the nav labels, which are catalog keys in the manifest. `shifts.count` shows a plural message, and
|
||||||
the views carry the visitor's language onto their links with `localeHref()`.
|
the views carry the visitor's language onto their links with `localeHref()`.
|
||||||
@@ -39,8 +42,8 @@ Your backend must expose two routes; the plugin treats any non-2xx as a recovera
|
|||||||
|
|
||||||
| Route | Request | Success | Response body |
|
| Route | Request | Success | Response body |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `GET /shifts` | `Accept: application/json`, optional `?assignee=<who>` | `200` | JSON array of `{ id, title, assignee, start, end }` (all strings; missing fields coerce to `""`). With `assignee`, only that person's rows |
|
| `GET /shifts` | `Accept: application/json`, optional `?assigneeId=<id>` | `200` | JSON array of `{ id, title, assignee, assigneeId, start, end }` (all strings; missing fields coerce to `""`). With `assigneeId`, only that person's rows |
|
||||||
| `POST /shifts` | JSON body `{ title, assignee, start, end }` | `2xx` | ignored (the plugin POST-redirect-GETs back to the list) |
|
| `POST /shifts` | JSON body `{ title, assignee, assigneeId?, start, end }` | `2xx` | ignored (the plugin POST-redirect-GETs back to the list) |
|
||||||
|
|
||||||
Domain rules (overlap, capacity, time ordering) live in your backend — reject with a 4xx and the
|
Domain rules (overlap, capacity, time ordering) live in your backend — reject with a 4xx and the
|
||||||
form re-renders. The plugin only validates that `title` and `assignee` are non-empty.
|
form re-renders. The plugin only validates that `title` and `assignee` are non-empty.
|
||||||
@@ -54,4 +57,5 @@ cosmetically) — normalise to your backend's format there if it matters.
|
|||||||
A user sees the shift list once they hold the `scheduling:read` permission in Keto (and
|
A user sees the shift list once they hold the `scheduling:read` permission in Keto (and
|
||||||
`scheduling:write` to create). The one-command bootstrap grants both to the demo admin, so the seeded
|
`scheduling:write` to create). The one-command bootstrap grants both to the demo admin, so the seeded
|
||||||
`admin@plainpages.local` can use it immediately. "My shifts" needs no grant at all — signing in is
|
`admin@plainpages.local` can use it immediately. "My shifts" needs no grant at all — signing in is
|
||||||
its whole gate.
|
its whole gate; it lists the rows this demo upstream holds against the signed-in visitor's id, and
|
||||||
|
the demo's seeded rows belong to three made-up people, so a freshly seeded admin sees it empty.
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export default definePlugin({
|
|||||||
icon: "i-cal",
|
icon: "i-cal",
|
||||||
id: "scheduling",
|
id: "scheduling",
|
||||||
label: "scheduling.nav.section",
|
label: "scheduling.nav.section",
|
||||||
|
public: true, // the header gates nothing; each child names its own gate, and an empty header is dropped
|
||||||
}],
|
}],
|
||||||
|
|
||||||
// Roles this plugin introduces (docs + Keto seeding). Namespaced `<id>:<action>`.
|
// Roles this plugin introduces (docs + Keto seeding). Namespaced `<id>:<action>`.
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ function fakeCtx(opts: { body?: string; permissions?: string[]; url?: string; us
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SHIFTS: Shift[] = [
|
const SHIFTS: Shift[] = [
|
||||||
{ assignee: "Avery Kline", end: "12:00", id: "1", start: "08:00", title: "Morning desk" },
|
{ assignee: "Avery Kline", assigneeId: "019bdc1a-3f27-7c41-9a6e-2b1d4f8e05a3", end: "12:00", id: "1", start: "08:00", title: "Morning desk" },
|
||||||
{ assignee: "Blair Mora", end: "17:00", id: "2", start: "12:00", title: "Afternoon support" },
|
{ assignee: "Blair Mora", assigneeId: "019bdc1a-4a83-7de2-8f05-6c93a71be4d8", end: "17:00", id: "2", start: "12:00", title: "Afternoon support" },
|
||||||
];
|
];
|
||||||
const fakeUpstream = (over: Partial<ShiftsUpstream> = {}): ShiftsUpstream => ({ create: async () => {}, list: async () => SHIFTS, ...over });
|
const fakeUpstream = (over: Partial<ShiftsUpstream> = {}): ShiftsUpstream => ({ create: async () => {}, list: async () => SHIFTS, ...over });
|
||||||
|
|
||||||
@@ -63,11 +63,11 @@ test("createUpstream.list fetches /shifts, asks for JSON, and maps the rows", as
|
|||||||
const http = (async (url, init) => {
|
const http = (async (url, init) => {
|
||||||
seen = String(url);
|
seen = String(url);
|
||||||
assert.equal((init?.headers as Record<string, string>).accept, "application/json");
|
assert.equal((init?.headers as Record<string, string>).accept, "application/json");
|
||||||
return new Response(JSON.stringify([{ assignee: "A", end: "2", id: "x", start: "1", title: "T", extra: "ignored" }]), { status: 200 });
|
return new Response(JSON.stringify([{ assignee: "A", assigneeId: "019bdc1a-5b6e-7a90-b3c7-84f01d2ea9b6", end: "2", id: "x", start: "1", title: "T", extra: "ignored" }]), { status: 200 });
|
||||||
}) as typeof fetch;
|
}) as typeof fetch;
|
||||||
const shifts = await createUpstream(() => "http://up:4000/", http).list(); // trailing slash trimmed
|
const shifts = await createUpstream(() => "http://up:4000/", http).list(); // trailing slash trimmed
|
||||||
assert.equal(seen, "http://up:4000/shifts");
|
assert.equal(seen, "http://up:4000/shifts");
|
||||||
assert.deepEqual(shifts, [{ assignee: "A", end: "2", id: "x", start: "1", title: "T" }]);
|
assert.deepEqual(shifts, [{ assignee: "A", assigneeId: "019bdc1a-5b6e-7a90-b3c7-84f01d2ea9b6", end: "2", id: "x", start: "1", title: "T" }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("createUpstream throws UpstreamError carrying the status on a non-2xx", async () => {
|
test("createUpstream throws UpstreamError carrying the status on a non-2xx", async () => {
|
||||||
@@ -174,19 +174,20 @@ test("buildFormModel marks title/assignee required and attaches field errors", (
|
|||||||
|
|
||||||
// ---- the session-gated page: the visitor's own rows ----
|
// ---- the session-gated page: the visitor's own rows ----
|
||||||
|
|
||||||
test("my shifts asks the upstream for the visitor's own rows, and names them in the empty state", async () => {
|
test("my shifts scopes the upstream read by the visitor's id, and names them in the empty state", async () => {
|
||||||
const user: User = { email: "Blair.Mora@example.test", id: "01a06091-baa3-71f4-a068-4879972979ff", permissions: [] };
|
const user: User = { email: "Blair.Mora@example.test", id: "01a06091-baa3-71f4-a068-4879972979ff", permissions: [] };
|
||||||
const mine: Shift = { assignee: "blair.mora@example.test", end: "22:00", id: "3", start: "17:00", title: "Evening on-call" };
|
const mine: Shift = { assignee: "Blair Mora", assigneeId: user.id, end: "22:00", id: "3", start: "17:00", title: "Evening on-call" };
|
||||||
let asked: { assignee?: string } | undefined;
|
let asked: { assigneeId?: string } | undefined;
|
||||||
|
|
||||||
const upstream = fakeUpstream({ list: async (opts) => { asked = opts; return [mine]; } });
|
const upstream = fakeUpstream({ list: async (opts) => { asked = opts; return [mine]; } });
|
||||||
const r = asView(await myShifts(upstream)(fakeCtx({ url: "http://localhost/scheduling/mine", user })));
|
const r = asView(await myShifts(upstream)(fakeCtx({ url: "http://localhost/scheduling/mine", user })));
|
||||||
assert.equal(r.view, "mine");
|
assert.equal(r.view, "mine");
|
||||||
assert.deepEqual(asked, { assignee: "Blair.Mora@example.test" });
|
assert.deepEqual(asked, { assigneeId: "01a06091-baa3-71f4-a068-4879972979ff" }); // the id, never the address
|
||||||
const table = r.data["table"] as { emptyText: string; rows: { name: string }[] };
|
const table = r.data["table"] as { emptyText: string; rows: { name: string }[] };
|
||||||
assert.deepEqual(table.rows.map((row) => row.name), ["Evening on-call"]);
|
assert.deepEqual(table.rows.map((row) => row.name), ["Evening on-call"]);
|
||||||
assert.match(table.emptyText, /Blair\.Mora@example\.test/); // an empty page still says whose it is
|
assert.match(table.emptyText, /Blair\.Mora@example\.test/); // an empty page still says whose it is
|
||||||
|
|
||||||
// The route carries `session: true`, but the handler asserts the session itself rather than trusting it.
|
// `requireSession` narrows `ctx.user` from `User | null` to `User` — the one part of the route's
|
||||||
|
// `session: true` guarantee the contract cannot state in the handler's type.
|
||||||
await assert.rejects(async () => { await myShifts(fakeUpstream())(fakeCtx()); }, GuardError);
|
await assert.rejects(async () => { await myShifts(fakeUpstream())(fakeCtx()); }, GuardError);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ export const WRITE = "scheduling:write"; // the permission gating create
|
|||||||
|
|
||||||
export interface Shift {
|
export interface Shift {
|
||||||
id: string;
|
id: string;
|
||||||
assignee: string;
|
assignee: string; // display name, rendered in the table
|
||||||
|
assigneeId: string; // who the shift belongs to — an opaque id, the same one `ctx.user.id` carries
|
||||||
end: string;
|
end: string;
|
||||||
start: string;
|
start: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -47,9 +48,9 @@ export class UpstreamError extends Error {
|
|||||||
|
|
||||||
export interface ShiftsUpstream {
|
export interface ShiftsUpstream {
|
||||||
create(input: ShiftInput): Promise<void>;
|
create(input: ShiftInput): Promise<void>;
|
||||||
// `assignee` scopes the read at the source, which is where an ownership rule belongs (README →
|
// `assigneeId` scopes the read at the source, which is where an ownership rule belongs (README →
|
||||||
// Three tiers of "may I?"); without it the caller would hold everyone's rows to render one page.
|
// Three tiers of "may I?"); without it the caller would hold everyone's rows to render one page.
|
||||||
list(opts?: { assignee?: string }): Promise<Shift[]>;
|
list(opts?: { assigneeId?: string }): Promise<Shift[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// REST client over the upstream service (a stand-in for the customer's real backend). `fetch`
|
// REST client over the upstream service (a stand-in for the customer's real backend). `fetch`
|
||||||
@@ -69,7 +70,7 @@ export function createUpstream(baseUrl: () => string, fetchImpl: typeof fetch =
|
|||||||
if (!res.ok) throw new UpstreamError(`create shift failed (${res.status})`, res.status);
|
if (!res.ok) throw new UpstreamError(`create shift failed (${res.status})`, res.status);
|
||||||
},
|
},
|
||||||
async list(opts = {}) {
|
async list(opts = {}) {
|
||||||
const query = opts.assignee == null ? "" : `?${new URLSearchParams({ assignee: opts.assignee })}`;
|
const query = opts.assigneeId == null ? "" : `?${new URLSearchParams({ assigneeId: opts.assigneeId })}`;
|
||||||
const res = await fetchImpl(`${base()}/shifts${query}`, { headers: { accept: "application/json" } });
|
const res = await fetchImpl(`${base()}/shifts${query}`, { headers: { accept: "application/json" } });
|
||||||
if (!res.ok) throw new UpstreamError(`list shifts failed (${res.status})`, res.status);
|
if (!res.ok) throw new UpstreamError(`list shifts failed (${res.status})`, res.status);
|
||||||
const data: unknown = await res.json();
|
const data: unknown = await res.json();
|
||||||
@@ -82,7 +83,7 @@ const str = (v: unknown): string => (typeof v === "string" ? v : v == null ? ""
|
|||||||
|
|
||||||
function toShift(raw: unknown): Shift {
|
function toShift(raw: unknown): Shift {
|
||||||
const r = (raw ?? {}) as Record<string, unknown>;
|
const r = (raw ?? {}) as Record<string, unknown>;
|
||||||
return { assignee: str(r["assignee"]), end: str(r["end"]), id: str(r["id"]), start: str(r["start"]), title: str(r["title"]) };
|
return { assignee: str(r["assignee"]), assigneeId: str(r["assigneeId"]), end: str(r["end"]), id: str(r["id"]), start: str(r["start"]), title: str(r["title"]) };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- view models (pure; the EJS views read these) -----------------------------------
|
// ---- view models (pure; the EJS views read these) -----------------------------------
|
||||||
@@ -196,7 +197,9 @@ export function myShifts(upstream: ShiftsUpstream): RouteHandler {
|
|||||||
let shifts: Shift[] = [];
|
let shifts: Shift[] = [];
|
||||||
let error: string | undefined;
|
let error: string | undefined;
|
||||||
try {
|
try {
|
||||||
shifts = await upstream.list({ assignee: user.email });
|
// Join on the id, never the email: an address is user-changeable and can be reassigned to
|
||||||
|
// someone else, which would hand them the previous holder's rows.
|
||||||
|
shifts = await upstream.list({ assigneeId: user.id });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
ctx.log.warn("scheduling upstream unreachable", { error: String(err) });
|
ctx.log.warn("scheduling upstream unreachable", { error: String(err) });
|
||||||
error = ctx.t("scheduling.upstream.list");
|
error = ctx.t("scheduling.upstream.list");
|
||||||
|
|||||||
@@ -3,19 +3,20 @@
|
|||||||
// of the app: stdlib only, in-memory (state resets on restart), no auth. Point PLUGIN_SETTING_SCHEDULING_UPSTREAM
|
// of the app: stdlib only, in-memory (state resets on restart), no auth. Point PLUGIN_SETTING_SCHEDULING_UPSTREAM
|
||||||
// at your real service in production.
|
// at your real service in production.
|
||||||
//
|
//
|
||||||
// GET /shifts → 200 [ { id, title, assignee, start, end }, … ] (?assignee=<who> → only theirs)
|
// GET /shifts → 200 [ { id, title, assigneeId, assignee, start, end }, … ] (?assigneeId=<id> → only theirs)
|
||||||
// POST /shifts → 201 { id, … } (body: { title, assignee, start, end })
|
// POST /shifts → 201 { id, … } (body: { title, assignee, assigneeId?, start, end })
|
||||||
|
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { createServer } from "node:http";
|
import { createServer } from "node:http";
|
||||||
|
|
||||||
const PORT = Number(process.env.PORT ?? 4000);
|
const PORT = Number(process.env.PORT ?? 4000);
|
||||||
|
|
||||||
|
// `assigneeId` is the identity the rows are owned by — an opaque, stable subject id, which is what
|
||||||
|
// `ctx.user.id` carries. These are this demo's own people; a real backend joins on your IdP's ids.
|
||||||
const shifts = [
|
const shifts = [
|
||||||
{ id: randomUUID(), title: "Morning — Front desk", assignee: "avery.kline@plainpages.local", start: "2026-06-22 08:00", end: "2026-06-22 12:00" },
|
{ id: randomUUID(), title: "Morning — Front desk", assigneeId: "019bdc1a-3f27-7c41-9a6e-2b1d4f8e05a3", assignee: "Avery Kline", start: "2026-06-22 08:00", end: "2026-06-22 12:00" },
|
||||||
{ id: randomUUID(), title: "Afternoon — Support", assignee: "blair.mora@plainpages.local", start: "2026-06-22 12:00", end: "2026-06-22 17:00" },
|
{ id: randomUUID(), title: "Afternoon — Support", assigneeId: "019bdc1a-4a83-7de2-8f05-6c93a71be4d8", assignee: "Blair Mora", start: "2026-06-22 12:00", end: "2026-06-22 17:00" },
|
||||||
{ id: randomUUID(), title: "Evening — On-call", assignee: "casey.nguyen@plainpages.local", start: "2026-06-22 17:00", end: "2026-06-22 22:00" },
|
{ id: randomUUID(), title: "Evening — On-call", assigneeId: "019bdc1a-5b6e-7a90-b3c7-84f01d2ea9b6", assignee: "Casey Nguyen", start: "2026-06-22 17:00", end: "2026-06-22 22:00" },
|
||||||
{ id: randomUUID(), title: "Night — Escalations", assignee: "admin@plainpages.local", start: "2026-06-22 22:00", end: "2026-06-23 06:00" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const json = (res, status, body) => {
|
const json = (res, status, body) => {
|
||||||
@@ -35,13 +36,13 @@ const readBody = (req) =>
|
|||||||
createServer(async (req, res) => {
|
createServer(async (req, res) => {
|
||||||
const url = new URL(req.url ?? "/", "http://localhost");
|
const url = new URL(req.url ?? "/", "http://localhost");
|
||||||
if (url.pathname === "/shifts" && req.method === "GET") {
|
if (url.pathname === "/shifts" && req.method === "GET") {
|
||||||
const assignee = url.searchParams.get("assignee");
|
const assigneeId = url.searchParams.get("assigneeId");
|
||||||
if (assignee === null) return json(res, 200, shifts);
|
if (assigneeId === null) return json(res, 200, shifts);
|
||||||
return json(res, 200, shifts.filter((s) => s.assignee.toLowerCase() === assignee.toLowerCase()));
|
return json(res, 200, shifts.filter((s) => s.assigneeId === assigneeId));
|
||||||
}
|
}
|
||||||
if (url.pathname === "/shifts" && req.method === "POST") {
|
if (url.pathname === "/shifts" && req.method === "POST") {
|
||||||
const b = await readBody(req);
|
const b = await readBody(req);
|
||||||
const shift = { id: randomUUID(), assignee: String(b.assignee ?? ""), end: String(b.end ?? ""), start: String(b.start ?? ""), title: String(b.title ?? "") };
|
const shift = { id: randomUUID(), assignee: String(b.assignee ?? ""), assigneeId: String(b.assigneeId ?? ""), end: String(b.end ?? ""), start: String(b.start ?? ""), title: String(b.title ?? "") };
|
||||||
shifts.push(shift);
|
shifts.push(shift);
|
||||||
return json(res, 201, shift);
|
return json(res, 201, shift);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,6 @@ test("gatesSet names the gates a declaration sets, so discovery can refuse more
|
|||||||
assert.deepEqual(gatesSet({ session: true }), ["session"]);
|
assert.deepEqual(gatesSet({ session: true }), ["session"]);
|
||||||
assert.deepEqual(gatesSet({ permission: "x:read", public: true }), ["public", "permission"]);
|
assert.deepEqual(gatesSet({ permission: "x:read", public: true }), ["public", "permission"]);
|
||||||
assert.deepEqual(gatesSet({ permission: "x:read", public: true, session: true }), ["public", "session", "permission"]);
|
assert.deepEqual(gatesSet({ permission: "x:read", public: true, session: true }), ["public", "session", "permission"]);
|
||||||
// `false` is not a gate — only a set one counts, so { session: false } is an ungated route.
|
// Only `true` sets a gate, so a manifest spelling one `false` names none — which discovery refuses.
|
||||||
assert.deepEqual(gatesSet({ public: false, session: false }), []);
|
assert.deepEqual(gatesSet({ public: false, session: false }), []);
|
||||||
});
|
});
|
||||||
|
|||||||
+8
-8
@@ -240,20 +240,20 @@ export function buildAuthRoutes({ hydra, keto, kratos, kratosAdmin, menu, secure
|
|||||||
const routes: BuiltinRoute[] = [];
|
const routes: BuiltinRoute[] = [];
|
||||||
if (kratos) {
|
if (kratos) {
|
||||||
for (const [path, flowType] of Object.entries(AUTH_FLOWS)) {
|
for (const [path, flowType] of Object.entries(AUTH_FLOWS)) {
|
||||||
routes.push({ handler: flowPage(kratos, flowType, secureCookies), method: "GET", path });
|
routes.push({ handler: flowPage(kratos, flowType, secureCookies), method: "GET", path, public: true });
|
||||||
}
|
}
|
||||||
routes.push({ handler: logout(kratos, secureCookies), method: "POST", path: "/logout" });
|
routes.push({ handler: logout(kratos, secureCookies), method: "POST", path: "/logout", public: true });
|
||||||
}
|
}
|
||||||
if (hydra && kratos) {
|
if (hydra && kratos) {
|
||||||
const provider = { hydra, kratos };
|
const provider = { hydra, kratos };
|
||||||
routes.push({ handler: oauthLogin(provider, secureCookies), method: "GET", path: "/oauth2/login" });
|
routes.push({ handler: oauthLogin(provider, secureCookies), method: "GET", path: "/oauth2/login", public: true });
|
||||||
routes.push({ handler: consentScreen(provider, menu.branding.name), method: "GET", path: "/oauth2/consent" });
|
routes.push({ handler: consentScreen(provider, menu.branding.name), method: "GET", path: "/oauth2/consent", public: true });
|
||||||
routes.push({ handler: consentDecision(provider), method: "POST", path: "/oauth2/consent" });
|
routes.push({ handler: consentDecision(provider), method: "POST", path: "/oauth2/consent", public: true });
|
||||||
}
|
}
|
||||||
if (hydra) routes.push({ handler: oauthLogout(hydra), method: "GET", path: "/oauth2/logout" });
|
if (hydra) routes.push({ handler: oauthLogout(hydra), method: "GET", path: "/oauth2/logout", public: true });
|
||||||
if (kratos && kratosAdmin && keto) {
|
if (kratos && kratosAdmin && keto) {
|
||||||
routes.push({ handler: completeAuth({ keto, kratosAdmin, kratosPublic: kratos }, secureCookies), method: "GET", path: "/auth/complete" });
|
routes.push({ handler: completeAuth({ keto, kratosAdmin, kratosPublic: kratos }, secureCookies), method: "GET", path: "/auth/complete", public: true });
|
||||||
}
|
}
|
||||||
routes.push({ handler: errorSink, method: "GET", path: "/error" });
|
routes.push({ handler: errorSink, method: "GET", path: "/error", public: true });
|
||||||
return routes;
|
return routes;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -173,7 +173,7 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
// routes.ts, capability-gated on the wired clients) plus the two landing slots above.
|
// routes.ts, capability-gated on the wired clients) plus the two landing slots above.
|
||||||
const builtinRoutes: BuiltinRoute[] = [
|
const builtinRoutes: BuiltinRoute[] = [
|
||||||
...buildAuthRoutes({ hydra, keto, kratos, kratosAdmin, menu, secureCookies }),
|
...buildAuthRoutes({ hydra, keto, kratos, kratosAdmin, menu, secureCookies }),
|
||||||
{ handler: serveHome, method: "GET", path: "/" },
|
{ handler: serveHome, method: "GET", path: "/", public: true },
|
||||||
{ handler: serveDashboard, method: "GET", path: "/dashboard", session: true },
|
{ handler: serveDashboard, method: "GET", path: "/dashboard", session: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ function scaffold(t: TestContext, files: Record<string, string>): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const full = (id: string): string =>
|
const full = (id: string): string =>
|
||||||
`export default { apiVersion: "${HOST_API_VERSION}", nav: [{ id: "${id}:root", label: "${id}" }], ` +
|
`export default { apiVersion: "${HOST_API_VERSION}", nav: [{ id: "${id}:root", label: "${id}", public: true }], ` +
|
||||||
`routes: [{ method: "GET", path: "/", handler: () => ({ html: "${id}" }) }] };`;
|
`routes: [{ method: "GET", path: "/", public: true, handler: () => ({ html: "${id}" }) }] };`;
|
||||||
|
|
||||||
test("a missing plugins/ dir means zero plugins, not an error (clean clone)", async () => {
|
test("a missing plugins/ dir means zero plugins, not an error (clean clone)", async () => {
|
||||||
assert.deepEqual(await discoverPlugins({ dir: join(tmpdir(), "pp-does-not-exist-xyz") }), []);
|
assert.deepEqual(await discoverPlugins({ dir: join(tmpdir(), "pp-does-not-exist-xyz") }), []);
|
||||||
@@ -67,6 +67,10 @@ const badCases: Array<{ name: string; files: Record<string, string>; match: RegE
|
|||||||
{ name: "a route whose session flag is a truthy non-boolean is refused, not read as ungated", files: { "truthy/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", session: "yes", handler: () => ({ html: "x" }) }] };` }, match: /truthy.*session.*true/s },
|
{ name: "a route whose session flag is a truthy non-boolean is refused, not read as ungated", files: { "truthy/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", session: "yes", handler: () => ({ html: "x" }) }] };` }, match: /truthy.*session.*true/s },
|
||||||
{ name: "a nav node whose public flag is a truthy non-boolean is refused too", files: { "truthynav/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ id: "n", label: "N", public: 1 }] };` }, match: /truthynav.*public.*true/s },
|
{ name: "a nav node whose public flag is a truthy non-boolean is refused too", files: { "truthynav/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ id: "n", label: "N", public: 1 }] };` }, match: /truthynav.*public.*true/s },
|
||||||
{ name: "a nav node marked session AND permission is contradictory", files: { "contrasessnav/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ id: "n", label: "N", session: true, permission: "x:read" }] };` }, match: /contrasessnav.*session.*permission/s },
|
{ name: "a nav node marked session AND permission is contradictory", files: { "contrasessnav/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ id: "n", label: "N", session: true, permission: "x:read" }] };` }, match: /contrasessnav.*session.*permission/s },
|
||||||
|
// A gate is named, never forgotten: a route or node without one would be an open page nobody chose.
|
||||||
|
{ name: "a route naming no gate at all is refused, not served to everyone", files: { "nogate/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", handler: () => ({ html: "x" }) }] };` }, match: /nogate.*names no gate/s },
|
||||||
|
{ name: "a nav node naming no gate at all is refused too — a section header says `public` outright", files: { "nogatenav/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ id: "n", label: "N" }] };` }, match: /nogatenav.*names no gate/s },
|
||||||
|
{ name: "a gate set to false is refused — it reads as a gate but sets none", files: { "falsegate/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", public: false, handler: () => ({ html: "x" }) }] };` }, match: /falsegate.*public.*true/s },
|
||||||
// A permission name is <resource>:<action> wherever the manifest mentions one. Enforced here, not
|
// A permission name is <resource>:<action> wherever the manifest mentions one. Enforced here, not
|
||||||
// only in the admin GUI, so it holds for a plugin installed without that GUI.
|
// only in the admin GUI, so it holds for a plugin installed without that GUI.
|
||||||
{ name: "a route gating on a bare word", files: { "bare/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", permission: "admin", handler: () => ({ html: "x" }) }] };` }, match: /bare.*admin.*<resource>:<action>/s },
|
{ name: "a route gating on a bare word", files: { "bare/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", permission: "admin", handler: () => ({ html: "x" }) }] };` }, match: /bare.*admin.*<resource>:<action>/s },
|
||||||
@@ -139,7 +143,7 @@ test("a plugin may carry its own package.json, node_modules and dependencies", a
|
|||||||
"shop/node_modules/price-tag/package.json": `{ "name": "price-tag", "version": "1.0.0", "type": "module", "exports": "./index.js" }`,
|
"shop/node_modules/price-tag/package.json": `{ "name": "price-tag", "version": "1.0.0", "type": "module", "exports": "./index.js" }`,
|
||||||
"shop/node_modules/price-tag/index.js": `export default (n) => \`\${n} kr\`;`,
|
"shop/node_modules/price-tag/index.js": `export default (n) => \`\${n} kr\`;`,
|
||||||
"shop/plugin.ts": `import { definePlugin } from "@plainpages/plugin-api";\nimport price from "price-tag";\n` +
|
"shop/plugin.ts": `import { definePlugin } from "@plainpages/plugin-api";\nimport price from "price-tag";\n` +
|
||||||
`export default definePlugin({ apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", handler: () => ({ html: price(20) }) }] });`,
|
`export default definePlugin({ apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", public: true, handler: () => ({ html: price(20) }) }] });`,
|
||||||
});
|
});
|
||||||
|
|
||||||
const plugins = await discoverPlugins({ dir });
|
const plugins = await discoverPlugins({ dir });
|
||||||
|
|||||||
@@ -161,16 +161,18 @@ function shapeError(manifest: PluginManifest): string | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Every rule a declaration's gate must satisfy. A truthy non-boolean sets no gate at all, so
|
// Every rule a declaration's gate must satisfy. Exactly one gate, always: a missing one would be an
|
||||||
// `session: "yes"` would read as an open page; a permission name is `<resource>:<action>` because a
|
// open page nobody chose, and anything but `true` (a `false`, a `"yes"`) sets no gate while looking
|
||||||
// bare word names a role, and roles are groups here (README → Naming a permission).
|
// like it does. A permission name is `<resource>:<action>` because a bare word names a role, and
|
||||||
|
// roles are groups here (README → Naming a permission).
|
||||||
function gateError(what: string, gate: Gate | null | undefined): string | null {
|
function gateError(what: string, gate: Gate | null | undefined): string | null {
|
||||||
for (const flag of ["public", "session"] as const) {
|
for (const flag of ["public", "session"] as const) {
|
||||||
const value = gate?.[flag];
|
const value = gate?.[flag];
|
||||||
if (value !== undefined && typeof value !== "boolean") return `${what} sets ${flag} to ${JSON.stringify(value)}; a gate is declared with \`true\``;
|
if (value !== undefined && value !== true) return `${what} sets ${flag} to ${JSON.stringify(value)}; a gate is declared with \`true\``;
|
||||||
}
|
}
|
||||||
const gates = gatesSet(gate);
|
const gates = gatesSet(gate);
|
||||||
if (gates.length > 1) return `${what} sets ${gates.join(" and ")}; name one gate — public, session or permission`;
|
if (gates.length === 0) return `${what} names no gate; name exactly one — public, session or permission`;
|
||||||
|
if (gates.length > 1) return `${what} sets ${gates.join(" and ")}; name exactly one — public, session or permission`;
|
||||||
if (gate?.permission != null && !isValidPermissionName(gate.permission)) {
|
if (gate?.permission != null && !isValidPermissionName(gate.permission)) {
|
||||||
return `${what} gates on "${gate.permission}"; a permission name is <resource>:<action>, e.g. "things:read"`;
|
return `${what} gates on "${gate.permission}"; a permission name is <resource>:<action>, e.g. "things:read"`;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user