2 Commits

Author SHA1 Message Date
Sylvain Duchesne ea359488b6 Load the backend's env files from its own directory
The root `bun test` also runs the backend's tests, from the repository root, where
dotenv-flow found no backend/.env: they passed only while the root .env still carried
stale SEMAPPS_* lines, and failed on a clean one (HOME_URL undefined).
2026-11-02 21:50:24 +01:00
Sylvain Duchesne 223059c629 Run the pod provider from images with make start, and fix what a fresh stack exposed
- docker-compose-dev.yml: the redis host port is overridable (REDIS_PORT in the root
  .env, 6379 by default as in the sibling apps), and the pod provider, in host network
  mode, follows it for its queue and OIDC redis. All of ./data/fuseki is mounted at the
  image's SEMAPPS_FUSEKI_BASE, so account deletion removes the backups and, 24 h later,
  the secure dataset's files (it answered 500 without the mount).
- backend: GET /app served the app actor snapshot the mixin takes before it creates or
  replaces the access-need group, so a dataset's first boot lacked the link until a
  restart; the app service refreshes the snapshot from the store at the end of started().
- e2e smoke: the post-install poll sends Accept: application/ld+json; the pod answers 500
  to a container read in any other format.
- Docs: cookbook (Redis port, mounts, hot reload, dataset-named accounts, fresh-boot
  note), deployment note, env examples; claims about a parallel race are no longer stated
  as fact.
2026-11-02 21:46:37 +01:00
10 changed files with 159 additions and 29 deletions
+6
View File
@@ -45,6 +45,12 @@ NODE_ENV=
# Le backend lit son propre backend/.env (voir backend/.env.example).
# ── Infrastructure locale (`make start`) ───────────────────────────────────
# docker compose lit aussi ce fichier. Port hôte du Redis de docker-compose-dev.yml,
# vide = 6379. Le changer si un autre Redis occupe ce port, et reporter la même
# valeur dans SEMAPPS_QUEUE_SERVICE_URL de backend/.env.
REDIS_PORT=
# ── Frontend / Backend coupling ────────────────────────────────────────────
# URL du backend telle que vue par le navigateur.
VITE_BACKEND_URL=http://localhost:3005
@@ -19,20 +19,20 @@ One fixed port pair per app on localhost, the pod provider keeping 3000/5000:
| portejunes backend / frontend | 3004 / 4004 |
| **Festipod backend / app** (committed defaults) | **3005 / 4005** |
| fuseki (admin/admin) | 3030 |
| redis | 6379 |
| redis | 6379 (`REDIS_PORT`) |
| mailcatcher | 1080 (SMTP 1025) |
| arena | 4567 |
A developer whose ports differ overrides them in the two gitignored env files, never in committed code: `backend/.env` (`SEMAPPS_PORT`, `SEMAPPS_HOME_URL`, `SEMAPPS_FRONT_URL`, the Redis and Fuseki endpoints) and the root `.env` (`PORT` for the app, `VITE_BACKEND_URL`, `VITE_POD_PROVIDER_URL`). The e2e suites and `scripts/validate.ts` read the same values from the environment (`PORT` → app URL, `VITE_BACKEND_URL`, `VITE_POD_PROVIDER_URL`), falling back to 3005 / 4005 / 3000. The app origin (`FRONT_URL`) is the only registered OIDC redirect origin, so the app must be served exactly there. The pod provider frontend on :5000 must run whenever Festipod signs a user in through the browser: the pod's login and app-consent screens are its pages (the UI smoke spawns its own app and does not need it; `test:e2e-flow`, `test:e2e-rights` and `test:e2e-revocation` do).
A developer whose ports differ overrides them in the two gitignored env files, never in committed code: `backend/.env` (`SEMAPPS_PORT`, `SEMAPPS_HOME_URL`, `SEMAPPS_FRONT_URL`, the Redis and Fuseki endpoints) and the root `.env` (`PORT` for the app, `VITE_BACKEND_URL`, `VITE_POD_PROVIDER_URL`, and `REDIS_PORT`, which docker compose reads from that file). The e2e suites and `scripts/validate.ts` read the same values from the environment (`PORT` → app URL, `VITE_BACKEND_URL`, `VITE_POD_PROVIDER_URL`), falling back to 3005 / 4005 / 3000. The app origin (`FRONT_URL`) is the only registered OIDC redirect origin, so the app must be served exactly there. The pod provider frontend on :5000 must run whenever Festipod signs a user in through the browser: the pod's login and app-consent screens are its pages (the UI smoke spawns its own app and does not need it; `test:e2e-flow`, `test:e2e-rights` and `test:e2e-revocation` do).
## Bring up
1. Once: `cp backend/.env.example backend/.env` and `cp .env.example .env` (both gitignored; fill the shared-wallet values in the root one), then `pnpm install` at the root and in `backend/`.
2. Pod provider and infrastructure: `make start` (`docker-compose-dev.yml`: `activitypods/backend` in host network mode on 3000, `activitypods/frontend` on 5000, `semapps/jena-fuseki-webacl` on 3030 with `./data/fuseki`, `redis` on 6379 with `./data/redis`, mailcatcher, `activitypods/arena` configured by `docker/arena-dev.json`). Other targets: `make stop`, `make config`, `make upgrade`, `make attach-activitypods`. `./data/` is gitignored.
3. Festipod backend: `cd backend && pnpm dev`. Wait for "ServiceBroker with ... service(s) started successfully" (~11 s).
2. Pod provider and infrastructure: `make start` (`docker-compose-dev.yml`: `activitypods/backend` in host network mode on 3000, `activitypods/frontend` on 5000, `semapps/jena-fuseki-webacl` on 3030 with `./data/fuseki`, `redis` on `REDIS_PORT` (6379 by default) with `./data/redis`, mailcatcher, `activitypods/arena` configured by `docker/arena-dev.json`). Other targets: `make stop`, `make config`, `make upgrade`, `make attach-activitypods`. `./data/` is gitignored.
3. Festipod backend: `cd backend && pnpm dev`. Wait for "ServiceBroker with ... service(s) started successfully" (~11 s). `pnpm dev` hot-reloads a changed service file; restart the backend after editing one instead of trusting the reload. After a reload of `services/app.service.js`, pod webhooks still answered 200 but were no longer processed (no attendees collection, no Join handling; observed 2026-11-02, cause not established).
4. Festipod app: `pnpm dev` at the root (the frontend lives at the root, not in a `frontend/` directory; the root scripts run Bun, pnpm only launches them).
`backend/.env` follows the sibling layout: own dataset `festipod` and accounts dataset `settings-festipod`, `SEMAPPS_REDIS_CACHE_URL=` empty (no cache locally), queue on `redis://localhost:6379/15` (the siblings use dbs 12, 13 and 14). dotenv-flow does not override the process environment, so a variable exported in the shell wins over the file. If host port 6379 is already taken, publish redis elsewhere and point `SEMAPPS_QUEUE_SERVICE_URL` at it in `backend/.env`.
`backend/.env` follows the sibling layout: own dataset `festipod` and accounts dataset `settings-festipod`, `SEMAPPS_REDIS_CACHE_URL=` empty (no cache locally), queue on `redis://localhost:6379/15` (the siblings use dbs 12, 13 and 14). dotenv-flow does not override the process environment, so a variable exported in the shell wins over the file. If host port 6379 is already taken, set `REDIS_PORT` in the root `.env` and put the same port in `SEMAPPS_QUEUE_SERVICE_URL` of `backend/.env`. The compose publishes redis on that port and points the pod provider's queue and OIDC URLs at it. The pod provider runs in host network mode, so it reaches redis only through the host port: its image's own default is `localhost:6379`, which the compose replaces with `REDIS_PORT`.
## Account and resource flow (all VERIFIED 2026-10-09)
@@ -51,12 +51,20 @@ On a fresh pod, an `as:Event` POST lands at the pod root (`/alice/data/<uuid>`):
- **Fuseki's data dir (`./data/fuseki`) must stay on the SSD** (`/home`, NVMe). Measured: a signup is ~10 s on the SSD, 150–200 s on the spinning HDD (`/hdd-home`) — TDB2 fsyncs per write and the seek time dominates. The HDD mount also hosts the bulk of the host's files; keep fuseki off it.
- **Never replace `data/fuseki` with a symlink after the container exists.** Docker resolves the bind source when the container is CREATED; moving the data away and leaving a link behind leaves the running container with a broken/empty mount (symptom: signup 500 « Error when creating secure dataset … FileNotFoundException: /fuseki/system_files/… »). `docker restart fuseki` re-resolves and heals — until the next behind-its-back move.
- The e2e suites delete the accounts they create at the end of every run (user decision 2026-10-26): `DELETE http://localhost:3000/.account/:username` with the user's token (a user may delete only their own account). The platform tombstones the account, sends the Delete activity, and drops the fuseki dataset on a delay — so test data no longer accumulates and the periodic full wipe is no longer needed. `diag-invite.ts` is the only suite left without cleanup (manual diagnostic).
- The pod provider deletes files from disk, at its image's `SEMAPPS_FUSEKI_BASE` (`../data/fuseki`, i.e. `/app/data/fuseki` in the container):
- at once, the account's backups (`backups/`);
- 24 h later, its secure dataset (`databases/<user>`, `<user>Acl`, `<user>Mirror`, `configuration/<user>.ttl`), since Fuseki's own delete leaves those files behind.
`docker-compose-dev.yml` therefore mounts all of `./data/fuseki` there. Without any mount, every deletion answers 500 (`ENOENT … scandir '../data/fuseki/backups'`, VERIFIED 2026-11-02), the account stays tombstoned with its dataset in place, and the suites still pass. With only `backups/` mounted, the deletion answers 200 but the dataset files stay on disk (about 40 MB per test account).
- Never sign up a local pod account named after an existing dataset (`festipod`, `settings-festipod`, another app's): per the pod provider's code, the pod is written into that dataset, and deleting the account deletes the dataset's files and backups. The provider reserves `sparql`, `auth`, `common`, `data` and `settings`: of the dataset names, only `settings` is protected.
## Backend fresh-boot note
The Festipod backend has its OWN datasets, `festipod` (main, created SECURE at first boot — WebACL is on) and `settings-festipod` (accounts), named like the sibling apps' (`welcometomyplace`/`settings-wtmp`…). Never point it at the pod provider's `settings` dataset, and never delete or recreate `settings`: it holds the pod provider's accounts. A backend dataset reset: delete the two datasets and the secure dataset's files on disk (`./data/fuseki`), and empty the backend's Redis queue database. After a reset, pod accounts that had installed the app must install it again: the new backend knows none of their registrations. A deleted dataset re-created under the same name needs a Fuseki restart in between (`docker restart fuseki`), else every query fails with `ClosedException: already closed`. Changing `SEMAPPS_HOME_URL` (a new port, say) changes the app actor's URI, hence the app's identity: a persistent account keeps a participations collection whose `acl:Control` belongs to the previous app actor, so the backend refuses every Join of that account ("the pod refused the read grant of the contacts group…") — remove the `http://festipod.org/participations` link from that account's profile with its own token (`DELETE DATA` of the exact triple; `DELETE WHERE` answers 400) and the next Join creates a new collection.
On a FRESH backend dataset (after a wipe), the app's actor and its access-need group are created in parallel by the mixin's boot and can race: the actor can end up without `interop:hasAccessNeedGroup` (symptom: `GET /app` links a group that 404s; consents register zero grants). `backend/services/app.service.js` heals this at boot by polling for the group (up to 60 s) and attaching it. If the backend is restarted while its dataset is wiped, wait for the heal before running consented flows — or simply re-check `GET /app` links a group that resolves 200.
`GET /app` serves a snapshot of the app actor that the `@activitypods/app` mixin takes at boot BEFORE it creates or replaces the access-need group (VERIFIED 2026-11-02 in the mixin's code and live). On a fresh dataset (first boot, or after a wipe), that snapshot lacks `interop:hasAccessNeedGroup`, even though the store has it. On a boot that changes the access needs, it names the group just deleted, which answers 404. Either way it stayed wrong until the next restart. On the first `make start` stack, the smoke and the rights matrix failed on their "access-need group" prerequisites. `backend/services/app.service.js` refreshes the snapshot from the store at the end of its own `started()`.
The same handler first checks the link in the store and attaches the group when it is missing, polling up to 60 s. On a fresh dataset (2026-10-26), consents registered zero grants and `GET /app` linked a group that 404s. A one-shot version of that check found no group. The cause was not established: the mixin creates the actor, then the group, one after the other. The 404 matches the stale snapshot above. Before running consented flows on a fresh dataset, check that `GET /app` links a group that resolves 200.
## Teardown
@@ -19,7 +19,7 @@ last_checked: 2026-10-19
- The backend runs with WebACL on, as the reference apps do, so its main dataset must be SECURE: the backend creates it so when it does not exist, and refuses to start ("the main dataset is not secure") on an existing one created otherwise. Starting again from nothing (domains changed, or a dataset created before WebACL was on): delete the two datasets in Fuseki AND the secure dataset's files Fuseki's delete leaves on disk (`databases/<main>`, `<main>Acl`, `<main>Mirror`, `configuration/<main>.ttl`), empty the stack's Redis (its cache keeps serving the old app actor, keys and access needs), redeploy — then every user installs the app again. The exact steps are in the compose file's header. Two more traps, both met on the test server 2026-10-30:
- **A deleted dataset cannot be re-created under the same name in the same Fuseki process**: Jena keeps the closed storage of that location, and every query on the new dataset fails with `ClosedException: already closed` (500), so the backend crashes at boot. Restart Fuseki before re-creating it — on the test server it is the shared one, so every test app loses its triplestore meanwhile — or give the stack new dataset names (`MAIN_DATASET` / `SETTINGS_DATASET`, e.g. `festipod-test-2`).
- A "Model permissions violation: http://semapps.org/securedWebacl" in Fuseki's log at the backend's boot is EXPECTED: semapps probes the WebACL graph anonymously to check the dataset is secure and wants that refusal. The backend must reach Fuseki as the Fuseki user named exactly `admin` (`JENA_USER`), the only one the semapps permissions module grants a secure dataset to (semapps `ShiroEvaluator`, read in source).
- On a cold first boot, `GET <base>app` answers from an in-memory copy of the actor taken before the access-need group got linked, so it lacks `interop:hasAccessNeedGroup` until the backend restarts (the actor itself, `<base>as/actor/app`, is right). A real sign-in is unaffected — the pod reads the actor — but the rights matrix's programmatic install reads `/app`: restart the backend once after a first deployment.
- On a cold first boot, `GET <base>app` answered from an in-memory copy of the actor taken before the access-need group got linked, so it lacked `interop:hasAccessNeedGroup` until the backend restarted (the actor itself, `<base>as/actor/app`, was right). A real sign-in is unaffected — the pod reads the actor — but the rights matrix's programmatic install reads `/app`. Since 2026-11-02 the backend refreshes that copy from the store at the end of its boot (`backend/services/app.service.js`). That is verified locally without a Redis cache, not yet on Coolify, whose stack has one: after a first deployment, check that `GET <base>app` links a group before running the matrix.
**Verified 2026-10-19, locally, not yet on Coolify**: both images built from a clean export of the tree, run against the local pod provider behind a same-origin proxy that routes `/api` and `/.well-known` to the backend unstripped with the Host header kept (as Traefik does) — the login flow, the rights matrix (98/98 hard lines) and the revocation probe all pass; the same images at the origin root pass them too.
@@ -172,7 +172,7 @@ What was missing for Festipod's premise — discover other users' public events
- `acl:Append` behaviour, as above.
- The router's prefix stripping, with the second backend.
- The three provider contributions.
- Wiring the seam: DONE 2026-10-09 (T04 tree, tip `f8c2e3b`) — `FestipodDataContext` runs over Refine with the ActivityPods backend as the registered default; the join→collection loop is built but `@suspendu` until the app is installed on a pod (consent screen). Current state: `## Current gate state` above. The app-install blocker is CLEARED end-to-end 2026-10-12 (tips `3aa9131` → this state): the login → Solid-OIDC → consent → registration → app loop completes on the aligned stack, verified by an automated Playwright probe (fresh user: picker → login → consent → `/home` authenticated → event created → meeting point attached). The chain of causes, in the order they surfaced: (1) the pod provider's OIDC adapter resolves an unknown client_id by fetching it and requires a Solid-OIDC Client ID Document — `/app` serves it when the request carries the `JsonLdContext` header, the actor otherwise; (2) the platform stack has to run a coherent version — the app packages are pinned at npm latest (`@activitypods/app` 2.3.2, refine-providers 0.2.0) and the pod provider runs the release tag v2.3.12 (fresh pull + full data reset + the pod frontend needing `VITE_CONFIG_URL` in dev and redis remapped to :6390 per the cookbook); (3) on a cold start the actor's `interop:hasAccessNeedGroup` link races the access-need-group creation and is never attached — an idempotent heal in `backend/services/app.service.js` `started()` patches it when missing; (4) the auth provider's `registerApp` discovers the AppRegistration via a `Link: rel="interop:registeredAgent"` header the pod only returns when the requester's token identity (azp = the configured client_id) equals the registration's `registeredAgent`, which the pod stores under the app ACTOR uri — so the Solid-OIDC client_id IS the actor uri (`${backendUrl}/as/actor/app`), and `/as/actor/app` serves the actor / the Client ID Document by the same header trick through a standalone `client-id-doc` service (routing it to `app.get` deadlocked boot: the app service's own `awaitCreateComplete` polls the actor over HTTP while the service is still starting). The pinned contract carries a dated re-baseline section recording the registerApp/getAppStatus findings (authorized one-time re-read). The verify-compat shim was removed once the aligned stack made it redundant. A revoked app is now detected client-side (`4fc856f`, `d4fcb7e` — see `## T05`). The join→collection scenarios are re-enabled and green (5/5 smoke), and the full authenticated UI flow is banked as a durable gate: `bun run test:e2e-flow` (scripts/e2e-login-flow.ts — picker → login → consent → authenticated → event → meeting point, fresh user, benign-4xx classification, non-zero exit on any fatal ≥400). Platform fragility observed while un-suspending: hand-crafting an `apods:attendees` collection (instead of letting the backend attach it) crashed the festipod backend with an uncaught `Invalid URL` in `@activitypods/app` `pod-collections-handler.getGroupSlugFromCollectionUri(undefined)` — the scenario now reads the attached collection rather than building it; the crash path itself is platform-side.
- Wiring the seam: DONE 2026-10-09 (T04 tree, tip `f8c2e3b`) — `FestipodDataContext` runs over Refine with the ActivityPods backend as the registered default; the join→collection loop is built but `@suspendu` until the app is installed on a pod (consent screen). Current state: `## Current gate state` above. The app-install blocker is CLEARED end-to-end 2026-10-12 (tips `3aa9131` → this state): the login → Solid-OIDC → consent → registration → app loop completes on the aligned stack, verified by an automated Playwright probe (fresh user: picker → login → consent → `/home` authenticated → event created → meeting point attached). The chain of causes, in the order they surfaced: (1) the pod provider's OIDC adapter resolves an unknown client_id by fetching it and requires a Solid-OIDC Client ID Document — `/app` serves it when the request carries the `JsonLdContext` header, the actor otherwise; (2) the platform stack has to run a coherent version — the app packages are pinned at npm latest (`@activitypods/app` 2.3.2, refine-providers 0.2.0) and the pod provider runs the release tag v2.3.12 (fresh pull + full data reset + the pod frontend needing `VITE_CONFIG_URL` in dev and redis remapped to :6390 per the cookbook); (3) on a cold start the actor was seen without its `interop:hasAccessNeedGroup` link (cause not established, possibly the stale `GET /app` snapshot fixed 2026-11-02) — an idempotent heal in `backend/services/app.service.js` `started()` patches it when missing; (4) the auth provider's `registerApp` discovers the AppRegistration via a `Link: rel="interop:registeredAgent"` header the pod only returns when the requester's token identity (azp = the configured client_id) equals the registration's `registeredAgent`, which the pod stores under the app ACTOR uri — so the Solid-OIDC client_id IS the actor uri (`${backendUrl}/as/actor/app`), and `/as/actor/app` serves the actor / the Client ID Document by the same header trick through a standalone `client-id-doc` service (routing it to `app.get` deadlocked boot: the app service's own `awaitCreateComplete` polls the actor over HTTP while the service is still starting). The pinned contract carries a dated re-baseline section recording the registerApp/getAppStatus findings (authorized one-time re-read). The verify-compat shim was removed once the aligned stack made it redundant. A revoked app is now detected client-side (`4fc856f`, `d4fcb7e` — see `## T05`). The join→collection scenarios are re-enabled and green (5/5 smoke), and the full authenticated UI flow is banked as a durable gate: `bun run test:e2e-flow` (scripts/e2e-login-flow.ts — picker → login → consent → authenticated → event → meeting point, fresh user, benign-4xx classification, non-zero exit on any fatal ≥400). Platform fragility observed while un-suspending: hand-crafting an `apods:attendees` collection (instead of letting the backend attach it) crashed the festipod backend with an uncaught `Invalid URL` in `@activitypods/app` `pod-collections-handler.getGroupSlugFromCollectionUri(undefined)` — the scenario now reads the attached collection rather than building it; the crash path itself is platform-side.
- Both usage declarations are named `usage_festipod.md`, so the bare stem is ambiguous as a wikilink — refer to either by folder path.
- Driving `getAppStatus()`: DONE (`4fc856f`, `d4fcb7e`). Festipod relies on its `installed` member, which the reconstruction does not pin — recorded in the activitypods-providers usage declaration (constraint 9, need 3), together with its reliance on `create` sending `to` unchanged (constraint 10).
- The `activitypods-providers` reconstruction is stamped at the 0.1.5-era pin while the installed package is 0.2.0 — re-constructing the contract at the installed version is a pending decision (the source is read once at adoption time; a re-read is its own decision).
+1
View File
@@ -16,5 +16,6 @@ SEMAPPS_MAIN_DATASET=festipod
SEMAPPS_AUTH_ACCOUNTS_DATASET_NAME=settings-festipod
# Redis (format: redis://localhost:6379/0) — no cache locally; queue db 15 (the sibling apps use 12, 13, 14)
# The port is the one `make start` publishes: REDIS_PORT of the root .env when set, else 6379.
SEMAPPS_REDIS_CACHE_URL=
SEMAPPS_QUEUE_SERVICE_URL=redis://localhost:6379/15
+3 -1
View File
@@ -1,4 +1,6 @@
require('dotenv-flow').config();
// The backend's own env files, wherever the process starts: the root `bun test` runs the
// backend's tests too, from the repository root.
require('dotenv-flow').config({ path: require('path').join(__dirname, '..') });
module.exports = {
APP_NAME: process.env.SEMAPPS_APP_NAME,
+26 -18
View File
@@ -71,8 +71,8 @@ module.exports = {
},
actions: {
// Overrides the mixin's `get` (which returns `this.appActor` for every request) to
// answer the Solid-OIDC Client ID Document fetch as well. `this.appActor` is set by the
// mixin's `started()`, i.e. long before any request is served.
// answer the Solid-OIDC Client ID Document fetch as well. `this.appActor` is the mixin's
// snapshot of the actor, refreshed by this service's own started() (see there).
get: {
async handler(ctx) {
// moleculer-web exposes the raw request only when the route alias sets
@@ -84,11 +84,9 @@ module.exports = {
const isOidcClientIdDocument =
jsonLdContextKey !== undefined && String(headers[jsonLdContextKey]).includes('solid/oidc-context.jsonld');
// `this.appActor` is set at the very END of the mixin's started(). During boot,
// this action is already routable (the api gateway is up before the services
// finish starting) and `@activitypods/app`'s own `awaitCreateComplete` polls the
// actor over HTTP through this very route — waiting on an action that waits on the
// boot it blocks. Read the actor straight from the triplestore in that window.
// Moleculer registers this action only once every started() handler of the service has
// finished, by which time the mixin has set `this.appActor`: reading the actor from the
// triplestore when it is unset is a guard, not a boot path.
let actor = this.appActor;
if (!actor) {
try {
@@ -124,22 +122,22 @@ module.exports = {
},
async started() {
// Heal the actor ↔ access-need-group link. `@activitypods/app` attaches the group to the
// actor in an after.post hook of the group's creation, but the hook races the actor's own
// creation on a cold start (both run inside the broker's parallel started() phase): the
// group then exists while the actor never gets `interop:hasAccessNeedGroup`, and both the
// pod frontend (consent screen) and the pod backend (requirements discovery) read the
// needs from that link — an empty consent registers the app with zero grants and the
// register flow loops. The heal itself can ALSO lose the race: it may run before the
// mixin has created the group (observed 2026-10-26 on a fresh dataset — the one-shot
// heal found no group and silently did nothing), so it polls for the group instead of
// actor in an after.post hook of the group's creation. On a fresh dataset (2026-10-26)
// consents registered zero grants and a one-shot version of this heal found no group; the
// cause was not established — the mixin's started() creates the actor, then the group,
// before this handler runs. Both the pod frontend (consent screen)
// and the pod backend (requirements discovery) read the needs from that link — an empty
// consent registers the app with zero grants and the register flow loops — so the link
// is checked in the store and attached when missing, polling for the group rather than
// checking once. Idempotent: only patches when the link is missing.
const actorUri = urlJoin(CONFIG.HOME_URL, 'as/actor/app');
const actorHasLink = async () => {
const actor = await this.broker.call('ldp.resource.get', {
const readActor = () =>
this.broker.call('ldp.resource.get', {
resourceUri: actorUri,
accept: 'application/ld+json',
webId: 'system'
});
const linksGroup = actor => {
const link = actor && actor['interop:hasAccessNeedGroup'];
return Array.isArray(link) ? link.length > 0 : Boolean(link);
};
@@ -158,7 +156,7 @@ module.exports = {
const deadline = Date.now() + 60000;
for (;;) {
try {
if (await actorHasLink()) break;
if (linksGroup(await readActor())) break;
const group = await findGroup();
if (group) {
await this.broker.call('actors.attachAccessNeedGroup', {
@@ -176,5 +174,15 @@ module.exports = {
}
await new Promise(r => setTimeout(r, 2000));
}
// `get` serves `this.appActor`, the snapshot the mixin's started() takes BEFORE it creates
// or replaces the group: on a dataset's first boot that snapshot lacks the link, and on a
// boot that changes the access needs it names the group just deleted. `GET /app` kept
// that until the next restart (observed 2026-11-02), so refresh it from the store.
try {
const actor = await readActor();
if (linksGroup(actor)) this.appActor = actor;
} catch (e) {
this.logger.warn(`Could not refresh the app actor snapshot: ${e.message}`);
}
}
};
+96
View File
@@ -0,0 +1,96 @@
const { test, expect, describe, afterEach, setSystemTime } = require('bun:test');
const app = require('./app.service');
const ACTOR = 'http://localhost:3005/as/actor/app';
const GROUP = 'http://localhost:3005/interop/access-need-group/g1';
const actor = (extra = {}) => ({ id: ACTOR, type: ['Application', 'interop:Application'], ...extra });
const linked = () => actor({ 'interop:hasAccessNeedGroup': GROUP });
/**
* The app service as moleculer runs its own started(), after the mixin's: `snapshot` is the
* `this.appActor` the mixin left, `store` the actor the triplestore holds, `groups` what the
* group listing answers. Attaching a group writes the link into the store, as the mixin does.
* `failReadsFrom` makes the n-th actor read (1-based) and every later one throw.
*/
function service({ snapshot, store, groups = [], failReadsFrom = Infinity }) {
const calls = [];
const logs = { info: [], warn: [], error: [] };
let reads = 0;
const svc = {
appActor: snapshot,
logger: { info: m => logs.info.push(m), warn: m => logs.warn.push(m), error: m => logs.error.push(m) },
broker: {
call: async (action, params) => {
calls.push(action);
if (action === 'ldp.resource.get') {
reads += 1;
if (reads >= failReadsFrom) throw new Error('triplestore down');
return store;
}
if (action === 'access-needs-groups.list') return { 'ldp:contains': groups };
if (action === 'actors.attachAccessNeedGroup') {
store = { ...store, 'interop:hasAccessNeedGroup': params.accessNeedGroupUri };
return undefined;
}
throw new Error(`unexpected call ${action}`);
}
}
};
return { svc, calls, logs };
}
describe('app actor snapshot — GET /app serves the access-need group once the store links it', () => {
// The deadline test moves the clock; reset it even when a test times out mid-way.
afterEach(() => setSystemTime());
test("a dataset's first boot: the snapshot predates the link the store holds, so it is refreshed", async () => {
const { svc, calls } = service({ snapshot: actor(), store: linked() });
await app.started.call(svc);
expect(svc.appActor['interop:hasAccessNeedGroup']).toBe(GROUP);
expect(calls).not.toContain('actors.attachAccessNeedGroup');
});
test('the cold-start race: the store lacks the link too, the heal attaches the group, then the snapshot follows', async () => {
const { svc, calls, logs } = service({ snapshot: actor(), store: actor(), groups: [{ id: GROUP }] });
await app.started.call(svc);
expect(calls).toContain('actors.attachAccessNeedGroup');
expect(logs.info.some(m => m.includes(GROUP))).toBe(true);
expect(svc.appActor['interop:hasAccessNeedGroup']).toBe(GROUP);
});
test('a later boot: the snapshot already carries the link and keeps it', async () => {
const { svc } = service({ snapshot: linked(), store: linked() });
await app.started.call(svc);
expect(svc.appActor['interop:hasAccessNeedGroup']).toBe(GROUP);
});
test('a boot that changes the access needs: the snapshot names the deleted group, the store the new one', async () => {
const { svc } = service({ snapshot: actor({ 'interop:hasAccessNeedGroup': `${GROUP}-old` }), store: linked() });
await app.started.call(svc);
expect(svc.appActor['interop:hasAccessNeedGroup']).toBe(GROUP);
});
test('no group ever appears: the heal gives up and the snapshot is not replaced by an unlinked actor', async () => {
const snapshot = actor();
const { svc, logs } = service({ snapshot, store: actor({ name: 'from the store' }) });
// The group listing answers empty; jump the clock past the heal's 60 s deadline at that
// point so the loop gives up without sleeping.
const call = svc.broker.call;
svc.broker.call = async (action, params) => {
if (action === 'access-needs-groups.list') setSystemTime(new Date(Date.now() + 61000));
return call(action, params);
};
await app.started.call(svc);
expect(logs.error.some(m => m.includes('No access-need group appeared'))).toBe(true);
expect(svc.appActor).toBe(snapshot);
});
test('a failed refresh read leaves the snapshot as it was and says so', async () => {
const snapshot = actor();
const { svc, logs } = service({ snapshot, store: linked(), failReadsFrom: 2 });
await app.started.call(svc);
expect(svc.appActor).toBe(snapshot);
expect(logs.warn.some(m => m.includes('refresh the app actor snapshot'))).toBe(true);
});
});
+6 -1
View File
@@ -24,9 +24,14 @@ services:
- ./data/backend/logs:/app/backend/logs:z
- ./data/backend/jwt:/app/backend/jwt:z
- ./data/backend/uploads:/app/backend/uploads:z
# Account deletion removes the account's dataset files and backups from disk, at the image's SEMAPPS_FUSEKI_BASE (../data/fuseki)
- ./data/fuseki:/app/data/fuseki:z
environment:
SEMAPPS_JENA_PASSWORD: admin
SEMAPPS_DEFAULT_LOCALE: en
# Host network: the pod provider reaches redis on its published host port (REDIS_PORT, default 6379)
SEMAPPS_QUEUE_SERVICE_URL: redis://localhost:${REDIS_PORT:-6379}/1
SEMAPPS_REDIS_OIDC_PROVIDER_URL: redis://localhost:${REDIS_PORT:-6379}/2
# Allow the Pod backend to access the app backend
# See https://docs.docker.com/network/drivers/host/
network_mode: 'host'
@@ -58,7 +63,7 @@ services:
expose:
- '6379'
ports:
- '6379:6379'
- '${REDIS_PORT:-6379}:6379'
volumes:
- ./data/redis:/data:z
command: ['redis-server', '--appendonly', 'yes']
@@ -173,10 +173,14 @@ Given("l'application Festipod est installée sur le Pod de la session", async fu
// Registration is confirmed asynchronously by the app backend (the Pod posts a
// CREATE to the user's outbox), and the registered shape-tree containers come
// with it. Wait for the Event DataRegistration before a scenario writes there.
// The Accept header is required: the pod answers 500 to a container read in any other
// format ("LDP containers can only be returned with JSON-LD format").
const eventContainer = `${pod.storage}/as/event`;
const end = Date.now() + 20000;
while (Date.now() < end) {
const status = await fetch(eventContainer, { headers: { Authorization: `Bearer ${pod.token}` } })
const status = await fetch(eventContainer, {
headers: { Accept: 'application/ld+json', Authorization: `Bearer ${pod.token}` },
})
.then(r => r.status)
.catch(() => 0);
if (status === 200) return;