Answer prose round 1: 0 🔴, 10 🟡 (0 misread); define code health, restore the topic index, give each decision a premise of its own
CI / full-gate (push) Successful in 3m39s
CI / full-gate (push) Successful in 3m39s
This commit is contained in:
@@ -69,8 +69,9 @@ jobs:
|
|||||||
if: always()
|
if: always()
|
||||||
run: |
|
run: |
|
||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
# Cleanup, and the runner's Docker config is shared (docs/decisions.md) — a lost race here
|
# This is cleanup: it must not fail a release that published, nor skip the overview job
|
||||||
# must not fail a release that published, nor skip the overview job that follows.
|
# that follows. The runner's Docker config is shared, so a logout can lose a race
|
||||||
|
# (docs/decisions.md → "CI docker logins share the runner host's Docker config").
|
||||||
docker logout gitea.larvit.se || true
|
docker logout gitea.larvit.se || true
|
||||||
docker logout docker.io || true
|
docker logout docker.io || true
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ change, sweep the prose you touched — this file, `README.md`, the example READ
|
|||||||
comments — and cut it back to what a competent reader could not infer:
|
comments — and cut it back to what a competent reader could not infer:
|
||||||
|
|
||||||
- **Delete history.** Git holds it. No "this moved from X", "used to be Y", "was tried and
|
- **Delete history.** Git holds it. No "this moved from X", "used to be Y", "was tried and
|
||||||
rejected", "(declined twice)", dated changelog entries, or the symptom that prompted a fix. Record
|
rejected", "(declined twice)", dated changelog entries (a decision's date in `docs/decisions.md`
|
||||||
the decision and the reason it *currently* turns on, nothing else.
|
is not one), or the symptom that prompted a fix. Record the decision and the reason it
|
||||||
|
*currently* turns on, nothing else.
|
||||||
- **Delete restatement.** A comment that says what the adjacent line says, a doc paragraph that
|
- **Delete restatement.** A comment that says what the adjacent line says, a doc paragraph that
|
||||||
re-explains a table above it, a file-map entry that expands the filename. The fix is deletion,
|
re-explains a table above it, a file-map entry that expands the filename. The fix is deletion,
|
||||||
not trimming.
|
not trimming.
|
||||||
@@ -69,8 +70,10 @@ branch, create a PR and merge it when the CI/CD turns green.
|
|||||||
|
|
||||||
## Decisions
|
## Decisions
|
||||||
|
|
||||||
In `docs/decisions.md`, each with its reasoning and the premise it rests on. A review honours them
|
Each title below is an entry in `docs/decisions.md`, with its reasoning and the premise it rests
|
||||||
rather than re-raising them.
|
on; a review honours them.
|
||||||
|
|
||||||
|
### Structure & contracts
|
||||||
|
|
||||||
- `src/` is grouped by concern, and the core ships no domain screens
|
- `src/` is grouped by concern, and the core ships no domain screens
|
||||||
- Plugins and config import the host only through the two barrels
|
- Plugins and config import the host only through the two barrels
|
||||||
@@ -88,6 +91,9 @@ rather than re-raising them.
|
|||||||
- `ctx.chrome` is lazily memoized, never built unconditionally
|
- `ctx.chrome` is lazily memoized, never built unconditionally
|
||||||
- A plugin-owned render always runs on that plugin's context
|
- A plugin-owned render always runs on that plugin's context
|
||||||
- Email is delegated to Kratos
|
- Email is delegated to Kratos
|
||||||
|
|
||||||
|
### Authorization
|
||||||
|
|
||||||
- The vocabulary is `User` → `Group` → `Permission`, and there is no `Role`
|
- The vocabulary is `User` → `Group` → `Permission`, and there is no `Role`
|
||||||
- A permission name is always `<resource>:<action>`, enforced at discovery
|
- A permission name is always `<resource>:<action>`, enforced at discovery
|
||||||
- Permission names are authored in plugin code; only grants live in Keto
|
- Permission names are authored in plugin code; only grants live in Keto
|
||||||
@@ -101,6 +107,9 @@ rather than re-raising them.
|
|||||||
- `users:write` and `groups:write` each amount to full administrative access
|
- `users:write` and `groups:write` each amount to full administrative access
|
||||||
- The CSRF token is unbound to the session
|
- The CSRF token is unbound to the session
|
||||||
- Plainpages says "user" everywhere, where Ory says "identity"
|
- Plainpages says "user" everywhere, where Ory says "identity"
|
||||||
|
|
||||||
|
### i18n
|
||||||
|
|
||||||
- The locale lives in the URL, never in a cookie
|
- The locale lives in the URL, never in a cookie
|
||||||
- The core building blocks carry the locale, so a plugin doesn't have to
|
- The core building blocks carry the locale, so a plugin doesn't have to
|
||||||
- `locale` and the i18n view locals are reserved by the host
|
- `locale` and the i18n view locals are reserved by the host
|
||||||
@@ -110,9 +119,15 @@ rather than re-raising them.
|
|||||||
- An unknown translation key renders as itself
|
- An unknown translation key renders as itself
|
||||||
- `t()` returns raw text, and the view escapes it
|
- `t()` returns raw text, and the view escapes it
|
||||||
- RTL is out of scope until there is a real use case
|
- RTL is out of scope until there is a real use case
|
||||||
|
|
||||||
|
### UI
|
||||||
|
|
||||||
- A dropdown is a `<button popovertarget>` and a `[popover]`, never a `<details>`
|
- A dropdown is a `<button popovertarget>` and a `[popover]`, never a `<details>`
|
||||||
- The document is the one scroller
|
- The document is the one scroller
|
||||||
- `ICON_NAMES` is a host-owned registry, not a plugin contract
|
- `ICON_NAMES` is a host-owned registry, not a plugin contract
|
||||||
|
|
||||||
|
### Build, test & release
|
||||||
|
|
||||||
- Dependencies install to `/node_modules`, above `WORKDIR /app`
|
- Dependencies install to `/node_modules`, above `WORKDIR /app`
|
||||||
- A container whose output a human then edits or deletes runs as the invoking user
|
- A container whose output a human then edits or deletes runs as the invoking user
|
||||||
- Anything the browser logs fails the E2E test that provoked it
|
- Anything the browser logs fails the E2E test that provoked it
|
||||||
|
|||||||
+53
-47
@@ -1,6 +1,7 @@
|
|||||||
# Decisions
|
# Decisions
|
||||||
|
|
||||||
"Goal N" is README → Goals; "code health" is the goal every project shares.
|
"Goal N" is README → Goals. "Code health" is the goal every project shares: the code is easy to
|
||||||
|
understand and change safely.
|
||||||
|
|
||||||
## `src/` is grouped by concern, and the core ships no domain screens
|
## `src/` is grouped by concern, and the core ships no domain screens
|
||||||
|
|
||||||
@@ -14,7 +15,8 @@ is a drop-in plugin (`examples/plugins/admin/`).
|
|||||||
|
|
||||||
## Plugins and config import the host only through the two barrels
|
## Plugins and config import the host only through the two barrels
|
||||||
|
|
||||||
2026-08-17, the maintainer. Goal 7. Valid while the barrels are the contract surface.
|
2026-08-17, the maintainer. Goal 7. Valid while plugin authors typecheck against a host tree rather
|
||||||
|
than a published package.
|
||||||
|
|
||||||
`@plainpages/plugin-api` → `plugin-api/index.ts` → `src/plugin-host/plugin-api.ts`, and
|
`@plainpages/plugin-api` → `plugin-api/index.ts` → `src/plugin-host/plugin-api.ts`, and
|
||||||
`#menu-config` → `src/ui/menu-config.ts`; never a relative `../../src/*` path. These two barrels are
|
`#menu-config` → `src/ui/menu-config.ts`; never a relative `../../src/*` path. These two barrels are
|
||||||
@@ -22,7 +24,7 @@ the whole contract surface, so don't "fix" either back to a relative path.
|
|||||||
|
|
||||||
## The Ory client types are contract-visible through the barrel
|
## The Ory client types are contract-visible through the barrel
|
||||||
|
|
||||||
2026-07-02, the maintainer. Goal 7. Valid while the barrel re-exports them.
|
2026-07-02, the maintainer. Goal 7. Valid while plugins get Ory admin clients through `ctx.system`.
|
||||||
|
|
||||||
`@plainpages/plugin-api` re-exports `KratosAdmin`, `KetoClient`, `HydraAdmin`, their DTOs and their
|
`@plainpages/plugin-api` re-exports `KratosAdmin`, `KetoClient`, `HydraAdmin`, their DTOs and their
|
||||||
error classes. Changing those shapes needs a major `apiVersion` bump, not a free refactor.
|
error classes. Changing those shapes needs a major `apiVersion` bump, not a free refactor.
|
||||||
@@ -87,8 +89,8 @@ uploads.
|
|||||||
|
|
||||||
Per-plugin databases and roles bound accidents, not hostile plugins: `PLUGIN_DB_SECRET` is in
|
Per-plugin databases and roles bound accidents, not hostile plugins: `PLUGIN_DB_SECRET` is in
|
||||||
`web`'s environment during `onBoot`, and a plugin already holds `ctx.system`'s Ory admin clients. So
|
`web`'s environment during `onBoot`, and a plugin already holds `ctx.system`'s Ory admin clients. So
|
||||||
cross-plugin DB isolation is containment, and README says so rather than implying a sandbox. This
|
cross-plugin DB isolation is containment, and README says so. This follows AGENTS.md → priority 7:
|
||||||
follows AGENTS.md → priority 7: crash-isolation is a non-goal.
|
crash-isolation is a non-goal.
|
||||||
|
|
||||||
`server.ts` still deletes the secret from `process.env` right after `loadConfig`, before discovery
|
`server.ts` still deletes the secret from `process.env` right after `loadConfig`, before discovery
|
||||||
imports any plugin module. That ordering is the whole point: move the line earlier if anything,
|
imports any plugin module. That ordering is the whole point: move the line earlier if anything,
|
||||||
@@ -118,8 +120,9 @@ and `src/compose.test.ts` for a rename.
|
|||||||
|
|
||||||
2026-08-19, the maintainer. Goal 7. Valid while no plugin needs an orderly drain of its pools.
|
2026-08-19, the maintainer. Goal 7. Valid while no plugin needs an orderly drain of its pools.
|
||||||
|
|
||||||
Adding to the context costs a minor bump and removing from it a major, so the shape errs small
|
Removing any field from the context costs a major bump, so all six stay. Adding one costs only a
|
||||||
elsewhere. Pools handed to a plugin are reaped on process exit.
|
minor, so the context otherwise gains a field only when a plugin needs it. Pools handed to a plugin
|
||||||
|
are reaped on process exit.
|
||||||
|
|
||||||
## `config/` is a plain dir with no `package.json` of its own
|
## `config/` is a plain dir with no `package.json` of its own
|
||||||
|
|
||||||
@@ -132,10 +135,10 @@ override ever needs dependencies, `config/` needs the package treatment `plugins
|
|||||||
|
|
||||||
2026-08-18, the maintainer. Goal 7. Valid while nothing is installed in the wild.
|
2026-08-18, the maintainer. Goal 7. Valid while nothing is installed in the wild.
|
||||||
|
|
||||||
Allowing it would cost a warning and a re-parse per file, not a break: Node detects module syntax,
|
Refusing is the safe direction: refuse→warn relaxes freely, while warn→refuse breaks installed
|
||||||
so even a `.js` helper loads. And an operator on a read-only third-party mount cannot apply the
|
plugins. Allowing it would cost only a warning and a re-parse per file, not a break: Node detects
|
||||||
remedy. Refused anyway because that direction is safe: refuse→warn relaxes freely, while warn→refuse
|
module syntax, so even a `.js` helper loads. The cost of refusing is that an operator on a read-only
|
||||||
breaks installed plugins.
|
third-party mount cannot apply the remedy.
|
||||||
|
|
||||||
## `examples/` mirrors the drop-in mount dirs
|
## `examples/` mirrors the drop-in mount dirs
|
||||||
|
|
||||||
@@ -163,10 +166,9 @@ the plugin's own keys render as bare keys on the pages it owns.
|
|||||||
|
|
||||||
## Email is delegated to Kratos
|
## Email is delegated to Kratos
|
||||||
|
|
||||||
2026-06-23, the maintainer. Goal 3. Valid while Kratos sends recovery and verification mail.
|
2026-06-23, the maintainer. Goal 3. Valid while Kratos owns the recovery and verification flows.
|
||||||
|
|
||||||
Kratos renders and sends recovery and verification mail; `web` never touches SMTP. Customization is
|
`web` never touches SMTP. Mail is customized through Kratos' `courier.template_override_path`.
|
||||||
Kratos' `courier.template_override_path`, not app code.
|
|
||||||
|
|
||||||
## The vocabulary is `User` → `Group` → `Permission`, and there is no `Role`
|
## The vocabulary is `User` → `Group` → `Permission`, and there is no `Role`
|
||||||
|
|
||||||
@@ -182,9 +184,10 @@ per-row tier.
|
|||||||
2026-08-05, the maintainer. Goal 7. Valid while permissions are named by plugin authors.
|
2026-08-05, the maintainer. Goal 7. Valid while permissions are named by plugin authors.
|
||||||
|
|
||||||
Examples: `scheduling:read`, `users:write`. A bare word names who someone is (a role), and roles are
|
Examples: `scheduling:read`, `users:write`. A bare word names who someone is (a role), and roles are
|
||||||
groups here. `isValidPermissionName` in `plugin-host/plugin.ts`, checked by `shapeError` over every
|
groups here. `shapeError` checks every route, nav and profile-menu `permission`, and every declared
|
||||||
route, nav and profile-menu `permission` and every declared name, refuses the boot like any other
|
name, with `isValidPermissionName` (`plugin-host/plugin.ts`). A bad name refuses the boot like any
|
||||||
manifest rule. It is not left to the admin GUI, which an operator removes by not copying it in.
|
other manifest rule. The check is not left to the admin GUI, which an operator removes by not
|
||||||
|
copying it in.
|
||||||
|
|
||||||
## Permission names are authored in plugin code; only grants live in Keto
|
## Permission names are authored in plugin code; only grants live in Keto
|
||||||
|
|
||||||
@@ -236,16 +239,13 @@ function of the transport verb, and a route table must answer "what does this ne
|
|||||||
|
|
||||||
## A gate is one of `public`, `session` or `permission`, named exactly once
|
## A gate is one of `public`, `session` or `permission`, named exactly once
|
||||||
|
|
||||||
2026-09-02, the maintainer. Goal 7. Valid while the central override's `groups` builds header nodes
|
2026-09-02, the maintainer. Goal 7. Valid while routes and nav nodes are declared in plugin
|
||||||
without a gate.
|
manifests.
|
||||||
|
|
||||||
A route or nav node names exactly one gate. Discovery refuses none, two, and a flag spelled anything
|
A route or nav node names exactly one gate. Discovery refuses none, two, and a flag spelled anything
|
||||||
but `true`, so a forgotten gate fails the boot rather than publishing a page. `src/auth/gate.ts` is
|
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 that the plugin router, the host's own route table and the menu all read.
|
the one home of the rule that the plugin router, the host's own route table and the menu all read.
|
||||||
|
The rule binds manifests only; the comment on `allows` says why a node without a gate passes it.
|
||||||
Exactly-one-gate is a discovery-time rule on manifests, not a runtime invariant. `allows({}, user)`
|
|
||||||
stays open by design, because the central override's `groups` builds header nodes that carry no
|
|
||||||
gate; making `allows` fail closed would hide every operator-grouped section.
|
|
||||||
|
|
||||||
`session` exists because a plugin whose data is the visitor's own (their upstream account, their own
|
`session` exists because a plugin whose data is the visitor's own (their upstream account, their own
|
||||||
tokens) has no distinction a permission could name. The alternative, granting every newly
|
tokens) has no distinction a permission could name. The alternative, granting every newly
|
||||||
@@ -258,7 +258,8 @@ user-changeable and can be reassigned to someone who would then inherit the prev
|
|||||||
2026-09-02, the maintainer. Goal 9. Valid while `examples/plugins/scheduling` stays a teaching
|
2026-09-02, the maintainer. Goal 9. Valid while `examples/plugins/scheduling` stays a teaching
|
||||||
artifact rather than a maintained product.
|
artifact rather than a maintained product.
|
||||||
|
|
||||||
An example is read far more often than it is changed, and each page reads top to bottom on its own.
|
Each page then reads top to bottom on its own, and an example rarely changes, so the two copies cost
|
||||||
|
little.
|
||||||
|
|
||||||
## A `:read`-only holder is never shown a write affordance
|
## A `:read`-only holder is never shown a write affordance
|
||||||
|
|
||||||
@@ -269,19 +270,20 @@ remove. The permission picker still renders, disabled, because seeing who holds
|
|||||||
of `:read`. A write-intent GET (a create form, a delete-confirm page) is the exception to
|
of `:read`. A write-intent GET (a create form, a delete-confirm page) is the exception to
|
||||||
`actionForMethod` and gates on `:write`.
|
`actionForMethod` and gates on `:write`.
|
||||||
|
|
||||||
Guards against self-lockout go with it, since undoing one needs a `curl` against Keto:
|
Three guards go with it:
|
||||||
|
|
||||||
- You cannot revoke your own direct grants.
|
- You cannot revoke your own direct grants, since undoing that self-lockout needs a `curl` against
|
||||||
|
Keto.
|
||||||
- A permission held through a group renders ticked but disabled, because unticked would state the
|
- A permission held through a group renders ticked but disabled, because unticked would state the
|
||||||
opposite of the truth.
|
opposite of the truth.
|
||||||
- Unticking a permission on a group you are in, removing yourself (or a nested group you are in)
|
- Unticking a permission on a group you are in, removing yourself (or a nested group you are in)
|
||||||
from its members, or deleting the group is refused when you would lose a permission you don't hold
|
from its members, or deleting the group is refused when you would lose a permission you don't hold
|
||||||
directly.
|
directly. Undoing that self-lockout would need a `curl` against Keto too.
|
||||||
|
|
||||||
## `users:write` and `groups:write` each amount to full administrative access
|
## `users:write` and `groups:write` each amount to full administrative access
|
||||||
|
|
||||||
2026-08-05, the maintainer. Goal 2. Valid while `groups:write` can add anyone to any group and
|
2026-08-05, the maintainer. Goal 2. Valid while the admin GUI splits its write permissions by
|
||||||
`users:write` can mint a recovery code.
|
resource.
|
||||||
|
|
||||||
`groups:write` adds you to any group, including one holding every permission; `users:write` mints a
|
`groups:write` adds you to any group, including one holding every permission; `users:write` mints a
|
||||||
recovery code for any account. The containment the split buys is real on the read half only:
|
recovery code for any account. The containment the split buys is real on the read half only:
|
||||||
@@ -291,8 +293,8 @@ recovery code for any account. The containment the split buys is real on the rea
|
|||||||
|
|
||||||
2026-10-30, the maintainer. Goal 5. Valid while the session cookie stays `SameSite=Lax` or stricter.
|
2026-10-30, the maintainer. Goal 5. Valid while the session cookie stays `SameSite=Lax` or stricter.
|
||||||
|
|
||||||
`src/auth/csrf.ts`; README → Security model. It is the usual cost of a signed double-submit token,
|
Leaving the token unbound is the usual cost of a signed double-submit token (`src/auth/csrf.ts`;
|
||||||
covered by `SameSite=Lax` and HSTS.
|
README → Security model). `SameSite=Lax` and HSTS cover that cost.
|
||||||
|
|
||||||
## Plainpages says "user" everywhere, where Ory says "identity"
|
## Plainpages says "user" everywhere, where Ory says "identity"
|
||||||
|
|
||||||
@@ -305,11 +307,11 @@ House style, not a renamed concept. The single exception is the `Identity` DTO i
|
|||||||
|
|
||||||
2026-08-03, the maintainer. Goal 5. Valid while a page's language should show in its address.
|
2026-08-03, the maintainer. Goal 5. Valid while a page's language should show in its address.
|
||||||
|
|
||||||
The order is `?locale=sv-SE` → `Accept-Language` → `en-US`, and when the URL asked for a locale the
|
The host resolves the locale from `?locale=sv-SE`, then `Accept-Language`, then `en-US`. When the
|
||||||
host carries it onto the links it renders. A cookie would make a page's language invisible in its
|
URL asked for a locale, the host carries it onto the links it renders. A cookie would make a page's
|
||||||
address and unshareable; the cost is that a plugin wraps its own hrefs. Matching is exact on a full
|
language invisible in its address and unshareable; the cost is that a plugin wraps its own hrefs.
|
||||||
tag (`sv-FI` ≠ `sv-SE`), except that a lone language from `Accept-Language` takes the first regional
|
Matching is exact on a full tag (`sv-FI` ≠ `sv-SE`), except that a lone language from
|
||||||
catalog for it.
|
`Accept-Language` takes the first regional catalog for it.
|
||||||
|
|
||||||
## The core building blocks carry the locale, so a plugin doesn't have to
|
## The core building blocks carry the locale, so a plugin doesn't have to
|
||||||
|
|
||||||
@@ -407,8 +409,7 @@ by `.table-wrap`'s `overflow`. Four rules hold it together:
|
|||||||
That `aria-expanded` guarantee is the declarative attribute's alone: open a panel from script and no
|
That `aria-expanded` guarantee is the declarative attribute's alone: open a panel from script and no
|
||||||
engine applies it, so "enhancing" one of these triggers would cost it its accessibility. `<details>`
|
engine applies it, so "enhancing" one of these triggers would cost it its accessibility. `<details>`
|
||||||
stays where it means disclosure rather than popup: the nav tree. `shell.ejs` hand-rolls the same
|
stays where it means disclosure rather than popup: the nav tree. `shell.ejs` hand-rolls the same
|
||||||
block for the profile menu, because its trigger composes escaped user values and its Sign-out item
|
block for the profile menu, and its comment says why. Keep the two in step.
|
||||||
is a CSRF POST form. Keep the two in step.
|
|
||||||
|
|
||||||
## The document is the one scroller
|
## The document is the one scroller
|
||||||
|
|
||||||
@@ -495,18 +496,23 @@ destination, so `git mv src/app.ts notes.md` would otherwise read as docs and sk
|
|||||||
source file that was gone. `src/ci-gate.test.ts` locks the flags as a text guard, since the test
|
source file that was gone. `src/ci-gate.test.ts` locks the flags as a text guard, since the test
|
||||||
image ships neither `git` nor `bash`.
|
image ships neither `git` nor `bash`.
|
||||||
|
|
||||||
This is why the Docker Hub overview is `release-tooling/dockerhub-overview.md.tmpl` and not a `.md`:
|
Because a `.md` change skips the gate, the Docker Hub overview is
|
||||||
a release reads it and a unit test guards it, so a `.md` name would let a broken `{{VERSION}}` merge
|
`release-tooling/dockerhub-overview.md.tmpl`. A release reads it and a unit test guards it, so under
|
||||||
with its own guard skipped. `README.md` is the one markdown tests read, and a README-only change
|
a `.md` name a broken `{{VERSION}}` would merge with that guard skipped.
|
||||||
skips them. Accepted: the `apiVersion` samples `release-tooling/contract-version.test.ts` checks are
|
|
||||||
illustrative, and the copies that matter (`examples/`, `views/`, the template) are gated. A Hub page
|
`README.md` is the one markdown file tests read, so a README-only change skips them. That leaves two
|
||||||
command the README dropped (`release-tooling/dockerhub-overview.test.ts`) fails the next gated
|
gaps:
|
||||||
change.
|
|
||||||
|
- An `apiVersion` sample in the README can go stale unchecked by
|
||||||
|
`release-tooling/contract-version.test.ts`. Accepted, because the README's samples are
|
||||||
|
illustrative; the copies authors build from (`examples/`, `views/`, the template) are gated.
|
||||||
|
- A README-only change that drops a command the Hub page shows fails only the next gated change, in
|
||||||
|
`release-tooling/dockerhub-overview.test.ts`.
|
||||||
|
|
||||||
## CI docker logins share the runner host's Docker config
|
## CI docker logins share the runner host's Docker config
|
||||||
|
|
||||||
2026-07-04, the maintainer. Code health. Valid while releases keep a single-maintainer cadence;
|
2026-07-04, the maintainer. Code health. Valid while releases keep a single-maintainer cadence; if
|
||||||
serialize with a workflow `concurrency` group if it ever bites.
|
these races start costing re-runs, serialize the workflows with a `concurrency` group.
|
||||||
|
|
||||||
The act_runner is host-mode, so `docker login` and `logout` in the workflows mutate one shared
|
The act_runner is host-mode, so `docker login` and `logout` in the workflows mutate one shared
|
||||||
`~/.docker/config.json`. Concurrent jobs can race: one job's logout can 401 another's push, and the
|
`~/.docker/config.json`. Concurrent jobs can race: one job's logout can 401 another's push, and the
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ export function permissionName(resource: AdminResource, action: AdminAction): st
|
|||||||
// Every screen reads on GET/HEAD and mutates on POST. The route table and the in-handler guard both
|
// Every screen reads on GET/HEAD and mutates on POST. The route table and the in-handler guard both
|
||||||
// go through this rather than each spelling the permission out, so they cannot drift. Deliberately
|
// go through this rather than each spelling the permission out, so they cannot drift. Deliberately
|
||||||
// local: generalised, it would make authorization a function of the transport verb
|
// local: generalised, it would make authorization a function of the transport verb
|
||||||
// (docs/decisions.md).
|
// (docs/decisions.md → "`actionForMethod` is plugin-local and stays out of
|
||||||
|
// `@plainpages/plugin-api`").
|
||||||
export function actionForMethod(method: string): AdminAction {
|
export function actionForMethod(method: string): AdminAction {
|
||||||
const verb = method.toUpperCase();
|
const verb = method.toUpperCase();
|
||||||
return verb === "GET" || verb === "HEAD" ? "read" : "write";
|
return verb === "GET" || verb === "HEAD" ? "read" : "write";
|
||||||
|
|||||||
+3
-3
@@ -10,9 +10,9 @@ export interface Gate {
|
|||||||
session?: boolean; // any signed-in user, no grant to hold; anonymous is sent to /login
|
session?: boolean; // any signed-in user, no grant to hold; anonymous is sent to /login
|
||||||
}
|
}
|
||||||
|
|
||||||
// A gate naming none of the three allows everyone. Discovery refuses one on a route or a plugin's
|
// A gate naming none of the three allows everyone. Discovery refuses one on a plugin's route, nav
|
||||||
// nav node, so only the header nodes `config/menu.ts`'s `groups` builds get here without a gate,
|
// node or profile-menu entry, so only the header nodes `config/menu.ts`'s `groups` builds get here
|
||||||
// and failing closed would hide every section an operator grouped.
|
// without a gate, and failing closed would hide every section an operator grouped.
|
||||||
export function allows(gate: Gate, user: User | null): boolean {
|
export function allows(gate: Gate, user: User | null): boolean {
|
||||||
if (gate.public === true) return true;
|
if (gate.public === true) return true;
|
||||||
if (gate.session === true) return user !== null;
|
if (gate.session === true) return user !== null;
|
||||||
|
|||||||
@@ -31,9 +31,9 @@ test("init SQL gives each Ory service its own database, and leaves plugin databa
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// docs/decisions.md records that the driver runs the provisioning DDL in bootstrap and nothing
|
// A single value imported from the wrong module puts the driver in web's graph without changing
|
||||||
// else. A single value imported from the wrong module puts it in web's graph without changing
|
// behaviour, so nothing but this would notice (docs/decisions.md → "Plugin storage hands over
|
||||||
// behaviour, so nothing but this would notice.
|
// credentials, not a client").
|
||||||
test("the Postgres driver reaches bootstrap only, never web's import graph", () => {
|
test("the Postgres driver reaches bootstrap only, never web's import graph", () => {
|
||||||
const files = sourceFiles();
|
const files = sourceFiles();
|
||||||
assert.ok(files.length > 40, "walks the source tree");
|
assert.ok(files.length > 40, "walks the source tree");
|
||||||
|
|||||||
+2
-1
@@ -20,7 +20,8 @@ import { buildCredentials, storagePluginIds, type StorageCredentials } from "./p
|
|||||||
const config = loadConfig(); // validates the env (incl. enforced secrets) — fails loud at boot
|
const config = loadConfig(); // validates the env (incl. enforced secrets) — fails loud at boot
|
||||||
// The storage secret is in `config` now, so drop it from the environment before ANY plugin code
|
// The storage secret is in `config` now, so drop it from the environment before ANY plugin code
|
||||||
// runs: a plugin module's top level evaluates during discovery, long before onBoot. Defence in
|
// runs: a plugin module's top level evaluates during discovery, long before onBoot. Defence in
|
||||||
// depth, not a boundary (docs/decisions.md) — and only ever move this line earlier, never later.
|
// depth, not a boundary (docs/decisions.md → "The trust boundary is the `web` process, not the
|
||||||
|
// plugin") — and only ever move this line earlier, never later.
|
||||||
delete process.env["PLUGIN_DB_SECRET"];
|
delete process.env["PLUGIN_DB_SECRET"];
|
||||||
// App-level logger: structured, OTLP-capable when OTLP_ENDPOINT is set. The hot path clones it
|
// App-level logger: structured, OTLP-capable when OTLP_ENDPOINT is set. The hot path clones it
|
||||||
// per request for access logging + a trace span (src/http/request-tracking.ts); console-only otherwise.
|
// per request for access logging + a trace span (src/http/request-tracking.ts); console-only otherwise.
|
||||||
|
|||||||
@@ -60,7 +60,9 @@
|
|||||||
<% if (user.email) { %>
|
<% if (user.email) { %>
|
||||||
<%# Signed in: the same popover block as the menu partial, hand-rolled because this one's
|
<%# Signed in: the same popover block as the menu partial, hand-rolled because this one's
|
||||||
trigger composes escaped user values and its Sign-out item is a CSRF POST form, neither of
|
trigger composes escaped user values and its Sign-out item is a CSRF POST form, neither of
|
||||||
which the partial's Item shapes cover (docs/decisions.md). Keep the two in step. %>
|
which the partial's Item shapes cover (docs/decisions.md → "A dropdown is a
|
||||||
|
`<button popovertarget>` and a `[popover]`, never a `<details>`"). Keep the two in
|
||||||
|
step. %>
|
||||||
<div class="menu" style="flex:1 1 auto">
|
<div class="menu" style="flex:1 1 auto">
|
||||||
<button class="profile" type="button" popovertarget="profile-menu">
|
<button class="profile" type="button" popovertarget="profile-menu">
|
||||||
<span class="avatar" aria-hidden="true"><%= user.initials %></span>
|
<span class="avatar" aria-hidden="true"><%= user.initials %></span>
|
||||||
|
|||||||
Reference in New Issue
Block a user