Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 706ee132da | |||
| a61c8704c9 | |||
| 18fc71ff36 | |||
| 6b48b24350 | |||
| 77d567b445 | |||
| 799de590aa | |||
| 3aa9131a01 | |||
| 84a0882d32 | |||
| 659d7652c4 | |||
| 8dfc574346 | |||
| 13d9f179ef | |||
| f8c2e3b809 | |||
| c195211629 | |||
| b09884d586 | |||
| e17398d86d | |||
| e27cfa4e0a | |||
| 9823a45bab | |||
| 21b1223b84 | |||
| b1add6d8c8 | |||
| 184998a343 | |||
| df1db790ad | |||
| e282384e00 |
+48
-2
@@ -37,12 +37,58 @@ FESTIPOD_SHARED_WALLET_FILE_BASE64=
|
||||
FESTIPOD_AUTO_SEED=
|
||||
|
||||
# ── Serveur ────────────────────────────────────────────────────────────────
|
||||
# Port HTTP du serveur (défaut 3000).
|
||||
PORT=3000
|
||||
# Port HTTP du serveur (4000 : l'app est servie sur l'origine :4000 de la pile locale).
|
||||
PORT=4000
|
||||
|
||||
# NODE_ENV=production bascule `bun run start` (pas de HMR). En dev, laisser vide.
|
||||
NODE_ENV=
|
||||
|
||||
# ── Backend ActivityPods ───────────────────────────────────────────────────
|
||||
# URL publique du backend Festipod (écoute sur le port 3003).
|
||||
SEMAPPS_HOME_URL=http://localhost:3003
|
||||
|
||||
# URL publique du frontend (utilisée par le backend pour les OIDC redirects, etc.)
|
||||
SEMAPPS_FRONT_URL=http://localhost:4000
|
||||
|
||||
# Endpoint SPARQL du Fuseki auquel se connecte le backend.
|
||||
SEMAPPS_SPARQL_ENDPOINT=http://localhost:3030/
|
||||
|
||||
# Dataset Jena/Fuseki principal utilisé par le backend.
|
||||
SEMAPPS_MAIN_DATASET=settings
|
||||
|
||||
# Identifiants Jena/Fuseki.
|
||||
SEMAPPS_JENA_USER=admin
|
||||
SEMAPPS_JENA_PASSWORD=admin
|
||||
|
||||
# Redis : cache (db 3) et file des jobs (db 4).
|
||||
SEMAPPS_REDIS_CACHE_URL=redis://localhost:6390/3
|
||||
SEMAPPS_QUEUE_SERVICE_URL=redis://localhost:6390/4
|
||||
|
||||
# Dataset utilisé par le service auth.account.
|
||||
SEMAPPS_AUTH_ACCOUNTS_DATASET_NAME=settings
|
||||
|
||||
# Dépôt de shapes/shapetrees utilisé pour déclarer les besoins d'accès.
|
||||
SEMAPPS_SHAPE_REPOSITORY_URL=https://shapes.activitypods.org/
|
||||
|
||||
# Nom et description de l'application (backend).
|
||||
SEMAPPS_APP_NAME=Festipod
|
||||
SEMAPPS_APP_DESCRIPTION=Points de rencontre sur les événements publics
|
||||
SEMAPPS_APP_LANG=en,fr
|
||||
SEMAPPS_PORT=3003
|
||||
|
||||
# ── Frontend / Backend coupling ────────────────────────────────────────────
|
||||
# URL du backend telle que vue par le navigateur.
|
||||
VITE_BACKEND_URL=http://localhost:3003
|
||||
|
||||
# Client ID OAuth/Solid du backend (laisser vide pour dériver `${VITE_BACKEND_URL}/as/actor/app`).
|
||||
# Le client_id DOIT être l'URI de l'acteur app : le pod enregistre l'app sous cette URI et
|
||||
# ne sert l'en-tête `Link: rel="interop:registeredAgent"` (découverte de l'enregistrement
|
||||
# par le auth provider) que si le client_id égalise l'acteur.
|
||||
VITE_BACKEND_CLIENT_ID=
|
||||
|
||||
# Pod provider ActivityPods par défaut (optionnel). Vide → l'écran de login demande une URL.
|
||||
VITE_POD_PROVIDER_URL=http://localhost:3000
|
||||
|
||||
# ── Outillage dev (facultatif) ─────────────────────────────────────────────
|
||||
# Override du chemin local du polyfill @ng-eventually/sdk pour `pnpm run
|
||||
# overlay:polyfill` (overlay local réactif). Défaut = ../nextgraph/ng-eventually-js/packages/sdk.
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
type: _overview
|
||||
summary: How Festipod persists its data through the @ng-eventually/polyfill SDK — entities stored as documents placed by scope, direct SPARQL writes + reactive shape reads, SHEX stack, connected/demo modes, seed
|
||||
triggers:
|
||||
keywords: [nextgraph, "@ng-eventually", polyfill, watchShape, useShape, useShapeQuery, SHEX, shape, scope, "@graph", NURI, inbox, share, sparql, seed, wallet, FestipodData, ngSession, ngGraph, storeRegistry, bootstrap, document, entité, déconnexion, reconnexion]
|
||||
paths: ["src/shared/shapes/**", "src/shared/data/**", "src/shared/context/NextGraphContext.tsx", "src/shared/context/FestipodDataContext.tsx", "src/shared/utils/*", "src/shared/data/seedData.ts"]
|
||||
keywords: [nextgraph, "@ng-eventually", polyfill, watchShape, useShape, useShapeQuery, SHEX, shape, scope, "@graph", NURI, inbox, share, sparql, seed, wallet, FestipodData, ngSession, ngGraph, storeRegistry, bootstrap, document, entité, déconnexion, reconnexion, refine, provider, activitypods, participation, WAC]
|
||||
paths: ["src/shared/shapes/**", "src/shared/data/**", "src/shared/context/NextGraphContext.tsx", "src/shared/context/FestipodDataContext.tsx", "src/shared/utils/*", "src/shared/data/seedData.ts", "src/shared/refine/**"]
|
||||
---
|
||||
|
||||
# Data layer
|
||||
@@ -16,8 +16,11 @@ How Festipod **persists its data** through NextGraph (P2P, local-first, end-to-e
|
||||
|
||||
Each external interface this concept consumes lives in **its own folder**, holding the provider's engagement (pulled, version-pinned) and — once Festipod actually consumes it — our own declaration beside it.
|
||||
|
||||
- `polyfill-surface/` — [[contract_polyfill-surface]], **the data contract, PULLED from the provider and version-pinned**: the `@ng-eventually/polyfill` surface the app codes against, what it guarantees and what it refuses to promise. The ONLY reference — never open the provider's own sources. Beside it, [[usage_festipod]] — what the app *actually* calls, the conditions it needs, and the frictions measured against the engagement. **Frictions are how a need reaches the provider**: put it there, then signal it out of band.
|
||||
- `polyfill-surface/` — [[contract_polyfill-surface]], **the data contract, PULLED from the provider and version-pinned**: the `@ng-eventually/polyfill` surface the app codes against, what it guarantees and what it refuses to promise. The ONLY reference — never open the provider's own sources. Beside it, `polyfill-surface/usage_festipod.md` — what the app *actually* calls, the conditions it needs, and the frictions measured against the engagement. **Frictions are how a need reaches the provider**: put it there, then signal it out of band.
|
||||
- `indexing-layer/` — [[contract_indexing-layer]], the `@ng-helpers/indexing` engagement, PULLED and pinned on `v1.0.0`: creating an index, depositing references into it, curating it, reading it back. **Nothing consumes it yet** — no declaration is authored beside it, deliberately, since an empty one would say nothing.
|
||||
- `activitypods-providers/` — [[contract_activitypods-providers]], a RECONSTRUCTION rather than a pull: that package publishes no engagement, so the leaf is **our own pinned observation** of it, to be re-verified at every version bump. Beside it, `activitypods-providers/usage_festipod.md`, our declaration toward it. Nothing is wired yet — the Refine seam that would use it (`src/shared/refine/`) carries no backend.
|
||||
|
||||
Two declarations now share the name `usage_festipod.md`, so refer to either by its folder path rather than by wikilink — the stem alone no longer says which.
|
||||
|
||||
## Model & data
|
||||
- [[knowledge_nextgraph-stack]] — the SHEX shapes, the reactive ORM bindings, `build:orm`, injection through `ngSession`
|
||||
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
---
|
||||
type: contract
|
||||
summary: The Refine data, auth and live providers @activitypods/refine-providers exposes to an application over a Solid Pod — what each operation does, what a record looks like, and what the package does not offer
|
||||
built_from: https://github.com/activitypods/refine-providers.git/src
|
||||
built_version: 66616484b96fb2227cd8cdf02321aacfb4811365
|
||||
built_at: 2026-10-02
|
||||
---
|
||||
|
||||
# contract_activitypods-providers — `@activitypods/refine-providers`
|
||||
|
||||
**This is a RECONSTRUCTION, not the provider's promise.** The package publishes no engagement and no change policy, so this document is an observation made once, at adoption time, and pinned to the commit named in `built_version`. Nothing here is committed to by its authors: any release may move any of it. Re-observe at each upgrade — the pin is what makes a difference visible.
|
||||
|
||||
## Scope
|
||||
|
||||
Three Refine providers over a Solid Pod running ActivityPods: a `dataProvider` (CRUD over LDP containers), an `authProvider` (Solid-OIDC sign-in plus the app-registration dance ActivityPods requires), and a `liveProvider` (push over the Solid Notifications Protocol). Resources are not addressed by a path the caller chooses: each configured resource is mapped at runtime to an LDP container discovered in a WebID's Solid TypeIndex.
|
||||
|
||||
Two Ant Design components also ship (`antd-auth-page`, `antd-background-checks`) and are outside this scope — `antd` and `@ant-design/icons` are optional peer dependencies, so an app with its own interface consumes the three providers without them. `react-router` is likewise an optional peer dependency.
|
||||
|
||||
Package `@activitypods/refine-providers`, version 0.1.5 at the pinned commit. Peer dependency `@refinedev/core ^5.0.12`. Full typed shape: the package's shipped declarations, `./types` (`dist/types.d.ts`) and `./index` (`dist/index.d.ts`).
|
||||
|
||||
## Surface
|
||||
|
||||
```ts
|
||||
dataProvider({ resources, authProvider, jsonContext? }): DataProvider
|
||||
authProvider({ clientId, redirectUri?, allowAnonymous?, appStatusCheckInterval? }): AuthProvider
|
||||
liveProvider({ resources, authProvider, jsonContext? }): LiveProvider
|
||||
|
||||
type ResourceConfig = { shapeTreeUri?: string; types?: string | string[] }
|
||||
type ResourcesConfig = Record<string, ResourceConfig> // keyed by Refine resource name
|
||||
type JsonContext = string | string[] | Record<string, any>
|
||||
type AuthSession = { token: string; webId: string }
|
||||
```
|
||||
|
||||
`AuthProvider` is Refine's `AuthProvider` plus four members: `getSession(): AuthSession | undefined` (synchronous), `handleCallback(): Promise<{ webId, redirect }>`, `registerApp(webId?): Promise<string | undefined>`, `getAppStatus(): Promise<AppStatus>`. Its `login`/`register` take `{ issuer, redirect?, isSignup? }` — the Pod provider's base URL is a caller-supplied parameter, so the app must let the user pick one.
|
||||
|
||||
`DataProvider` implements `getList`, `getOne`, `getMany`, `create`, `update`, `deleteOne` and `getApiUrl`. Nothing else.
|
||||
|
||||
Utility functions are exported from the package root as well (notably the TypeIndex discovery and container resolution used internally); they are not part of what this document engages.
|
||||
|
||||
## Guarantees
|
||||
|
||||
Observed at the pinned commit.
|
||||
|
||||
**A record is a JSON-LD resource with an `id`.** `id` is the resource's own URI — `id` or `@id` of the fetched document, whichever is present; a resource carrying neither raises. The record also carries `@context` (the container's, on a listed record) and every other predicate verbatim. There is exactly ONE identifier: `getOne`, `update` and `deleteOne` take that same `id` and use it directly as the URL they fetch.
|
||||
|
||||
**`getList`** resolves the resource's container, fetches it whole, and maps `ldp:contains`. Refine's filters and sorters are then applied **in memory**, and pagination slices the result; `total` is the count before slicing. The filter set is broad (equality, `in`, substring, prefix/suffix, ordering, `between`, null checks, and `and`/`or` nesting), string comparison being case-insensitive unless the case-sensitive variant is used, and a filter field may be a dotted path.
|
||||
|
||||
**`create`** POSTs `{ "@context": jsonContext, "@type": <resolved types>, ...variables }` to the container, requires a `Location` response header, and returns the record re-fetched from it. The `@type` is the resource's configured `types` expanded through `jsonContext`, or, when only `shapeTreeUri` is configured, the `sh:targetClass` of the shape the shape tree points at.
|
||||
|
||||
**`update` merges — a partial write is safe.** It fetches the current resource, drops its `id` and `@context`, and PUTs `{ "@context": jsonContext, ...current, ...variables }`. So values the caller omits survive. The merge is **shallow**: a nested object in `variables` replaces its counterpart whole. It returns the record re-fetched after the write.
|
||||
|
||||
**`deleteOne`** DELETEs the resource and returns `{ id }` only.
|
||||
|
||||
**A configured resource's container is discovered, not configured.** Resolution reads the WebID document, then its `solid:publicTypeIndex`, then best-effort its `pim:preferencesFile` → `solid:privateTypeIndex`, and takes the first type registration whose `solid:forClass` matches the resource's resolved types and which carries a `solid:instanceContainer`. No match raises, naming the WebID and the types. A `shapeTreeUri` is fetched over plain HTTP, as is the shape it points at — so a shape tree the caller hosts itself works, provided it is reachable and carries `st:shape` → `sh:targetClass`.
|
||||
|
||||
**`jsonContext`** is sent as `@context` on every create and update, and is what expands configured CURIE types. It defaults to the ActivityStreams 2 context; an application with its own vocabulary passes its own.
|
||||
|
||||
**`liveProvider` pushes — the caller never polls.** It opens a `notify:WebSocketChannel2023` channel per topic, discovering the subscription endpoint from the Pod origin's `.well-known/solid`. Watching specific records subscribes to each record and reports `Update` → `updated`, `Delete`/`Remove` → `deleted`. Watching a list subscribes to the **container** and reports `Add` → `created`, `Remove` → `deleted`. Unsubscribing closes the socket and deletes the channel best-effort. A resource absent from `resources` yields a no-op unsubscribe rather than an error.
|
||||
|
||||
**Auth is Solid-OIDC with PKCE.** `login` redirects to the issuer's authorization endpoint with scope `openid webid offline_access`; `handleCallback`, called from the redirect route, completes the code grant and stores the ID token. The session's `webId` is the token's `webid` claim, and an expired token reads as no session. `logout` clears local state. `check` reports unauthenticated with `redirectTo: "/login"` unless `allowAnonymous` is set. `getIdentity` returns `{ id: webId, name, avatar }`, name and avatar read from the WebID document and the profile it links — ActivityPods' own profile vocabulary, unrelated to any vocabulary the app defines for its own user records. `registerApp` returns the AppRegistration URI when the app is registered and its grants current, and otherwise redirects the browser to the user's authorization agent and resolves `undefined`.
|
||||
|
||||
**Failures throw, and carry the HTTP status.** Every Pod call that returns non-2xx raises an `Error` with `status` and `body` attached, its message taken from the response body's error message when there is one. `onError` maps a 401 to logout.
|
||||
|
||||
## Non-guarantees
|
||||
|
||||
**Every read and write is scoped to the logged-in user's own Pod.** Container resolution always runs against the session's own WebID — in `getList`, in `create`, and in a list subscription alike. There is no parameter, filter or option through which an operation reaches another user's resources: `getList` returns what the current user holds, and nothing else. (`getOne`/`getMany` fetch a URI directly and so are not bound this way, but they require the caller to already hold that URI.)
|
||||
|
||||
**No server-side querying.** Filtering, sorting and pagination are applied after the whole container has been fetched, so cost grows with the container, not with the page.
|
||||
|
||||
**`getManyReference` is not implemented**, despite the data provider's own documentation mentioning it. Neither are `createMany`, `updateMany` and `deleteMany`.
|
||||
|
||||
**`getMany` drops what it cannot fetch.** Failed fetches are filtered out silently, so the returned array may be shorter than `ids` with no indication of which are missing.
|
||||
|
||||
**Caches are module-level and never invalidated** — shape trees by URI, type registrations by WebID, subscription endpoints by origin. A container that becomes available after a first lookup (the user granting access, for instance) is not seen until the page reloads, which is what the registration flow expects.
|
||||
|
||||
**`authProvider` installs process-global timers it never removes.** Unless `appStatusCheckInterval: false` is passed, construction registers a `setInterval` and a `visibilitychange` listener, with no teardown; constructing the provider more than once per page accumulates them.
|
||||
|
||||
**One session per page.** The token lives at a fixed `localStorage` key, so two concurrent sessions cannot coexist.
|
||||
|
||||
**A list subscription does not report field changes.** A container notifies about membership only, so a member's own predicates changing is invisible to a list; the record has to be watched directly.
|
||||
|
||||
**`types` wins over `shapeTreeUri` when both are set** — the opposite of what the configuration type's own documentation states. Configure exactly one. Likewise, when several type registrations match a resource's types, the first encountered wins, with no stated ordering.
|
||||
|
||||
**Unregistered apps read and write nothing.** An ActivityPods app must hold an AppRegistration before any data operation succeeds, and keeping it current is the app's business, through `registerApp` and `getAppStatus`.
|
||||
|
||||
## Change policy
|
||||
|
||||
None is published. The package carries no engagement document, no stated versioning commitment and, at the pinned commit, no git tags — its released versions are visible only as `package.json` version bumps. Treat every upgrade as potentially breaking: re-read the surface against the new commit and re-stamp this document. Divergences found between this reconstruction and observed behaviour are defects in this document, to be fixed here.
|
||||
|
||||
## Re-baseline 2026-10-12 (authorized one-time re-read of the installed 0.2.0)
|
||||
|
||||
The user authorized a single re-read of the installed package to resolve the login-loop investigation; these paragraphs pin what the first reconstruction had not observed. They stand until the next re-baseline.
|
||||
|
||||
**`registerApp` discovers the registration through a Link header, and the discovery key is the requester's identity.** `registerApp(webId)` fetches the user's WebID document, reads `interop:hasAuthorizationAgent`, then fetches the auth-agent document and parses its `Link` response header for `rel="http://www.w3.org/ns/solid/interop#registeredAgent"`; the first link's anchor is the AppRegistration URI. The pod provider only emits that header when the requesting app's identity — the access token's `azp`, i.e. the Solid-OIDC `client_id` — equals the registration's `registeredAgent`. The pod stores `registeredAgent` under the app actor URI (its authorize flow resolves the client_id document to the actor and passes the actor as `appUri`). CONSEQUENCE, verified end-to-end 2026-10-12: the `client_id` configured in `authProvider({clientId})` must be the app actor URI — not a separate document URI — or the lookup never matches and `registerApp` re-redirects to the consent screen forever.
|
||||
|
||||
**`registerApp`'s fallback is a browser redirect, not an error.** When no registration is found (or the granted needs are stale), it stores the current path in `localStorage` under a return-to key, then sets `window.location` to the auth-agent's `interop:hasAuthorizationRedirectEndpoint` with a `client_id` query parameter, and resolves `undefined`. The app resumes at the stored path after the consent dance; that path is what the app's callback stage must parse.
|
||||
|
||||
**`getAppStatus()` is a plain HTTP GET, and its shape is small.** It calls `GET /.well-known/app-status` on the pod provider's origin with the session token and returns the JSON body; the member the provider itself acts on is `upgradeNeeded` (boolean). A status probe that throws is treated by the provider's own upgrade check as fail-open (no upgrade). This resolves the previously documented gap on the `AppStatus` shape: `{ upgradeNeeded: boolean }` plus whatever the pod adds.
|
||||
|
||||
**The built-in status drive exists and is on by default.** Unless `appStatusCheckInterval: false` is passed, the auth provider installs a periodic + `visibilitychange` check that silently re-runs `registerApp` (→ consent redirect) when `upgradeNeeded` turns true. Festipod currently opts out (`appStatusCheckInterval: false`, friction 1 in the usage declaration) and drives the check on its own schedule instead.
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
type: usage
|
||||
summary: What Festipod consumes from @activitypods/refine-providers as one of its interchangeable Refine backends, and the conditions its own vocabulary and confidentiality model put on that backend
|
||||
against: 66616484b96fb2227cd8cdf02321aacfb4811365
|
||||
---
|
||||
|
||||
# usage_festipod — Festipod on `@activitypods/refine-providers`
|
||||
|
||||
Festipod is a mobile-first web application: users create **meeting points** grafted onto public events, and sign up to them. Its entire persistence goes through Refine, behind a **backend seam** that exists so the app can support several Refine backends and let the user switch between them at runtime. This package is one of the two backends targeted; the other is a Refine provider over NextGraph plus its polyfill. The seam's own contract — what a backend must answer before it can be added — lives in code, in `src/shared/refine/providers/types.ts`.
|
||||
|
||||
The seam is wired since 2026-10-09 (T04.e): this backend is the registered default, and Festipod reads and writes through it — `useList` over the shared `as:Event` container (split into events and meeting points by the bindings' `isInstance`), creates and updates through the data provider, and the embedded `apods:attendees` collection through the participation binding's `membersOf`. Joins/leaves are the one write beside Refine (the package implements no `custom`): an `as:Join`/`as:Leave` posted to the user's own outbox by `src/shared/data/outbox.ts`, per the platform flow recorded in the 2026-10-09 brief. What the list below declares is therefore measured where a runtime check exists and declared elsewhere; `against:` names the observation's commit, and the package publishes no engagement, so there is nothing to pull and nothing that can drift — what is recorded here is read against our own reconstruction, and re-read when it is re-stamped.
|
||||
|
||||
## Consumed surface
|
||||
|
||||
### Provider construction
|
||||
|
||||
- `authProvider({ clientId, redirectUri, appStatusCheckInterval: false })` — one call site, per backend selection. `appStatusCheckInterval` is disabled deliberately, see friction 1; Festipod drives the check itself.
|
||||
- `dataProvider({ authProvider, resources, jsonContext })` — one call site, handed the `authProvider` above.
|
||||
- `liveProvider({ authProvider, resources, jsonContext })` — same, so Refine can subscribe behind every query.
|
||||
|
||||
All three are built together, **per backend instance**, inside the seam's `build()`.
|
||||
|
||||
### Auth members
|
||||
|
||||
`login({ issuer, redirect })` and `register(...)` — Festipod supplies its own Pod-provider picker. `handleCallback()` from its own callback route. `logout()`. `check()`. `getSession()` for the `webId` and the token. `registerApp()` and `getAppStatus()`, the second one on Festipod's own schedule since the built-in interval is off.
|
||||
|
||||
### Resources and operations
|
||||
|
||||
Four resources, one per persisted entity: **events**, **meeting points**, **participations** and **user profiles**. Each is configured with `types` naming Festipod's own class URIs, and `jsonContext` carries the context that expands them.
|
||||
|
||||
Through Refine's hooks only, never the provider directly: `useList` → `getList`, `useCreate` → `create`, `useUpdate` → `update`, `useDelete` → `deleteOne`. `getOne` is reached by Refine's cache plumbing rather than an explicit call. The app mounts Refine with `liveMode: 'auto'` and `mutationMode: 'optimistic'`.
|
||||
|
||||
### Record handling
|
||||
|
||||
A record's `id` — the resource's URI — is the **only** identifier Festipod uses: the seam's `recordKey` and `domainId` both return it. `update` merging the fields it is given means the seam declares this backend as patching, and sends only what changed.
|
||||
|
||||
### Not consumed
|
||||
|
||||
- `shapeTreeUri` — exactly one of the two resource identifiers must be configured, and `types` names Festipod's classes directly, without a shape tree to host and keep reachable.
|
||||
- `getIdentity` — the signed-in identity and a Festipod user profile are two unrelated things in this app; the profile is read as an ordinary record, and only `getSession`'s `webId` is needed.
|
||||
- `getMany`, `getApiUrl`, `allowAnonymous`, and both Ant Design entry points — Festipod has its own interface.
|
||||
|
||||
## Constraints
|
||||
|
||||
What must keep holding for this backend to serve the app.
|
||||
|
||||
1. **Festipod's own vocabulary, not ActivityStreams.** The four entities are Festipod domain types defined by SHEX shapes this repo holds; none is an ActivityStreams type. A caller-supplied `jsonContext` and caller-supplied `types` are what make that possible, and both must stay caller-supplied.
|
||||
2. **The identifier a record is addressed by must stay the identifier other records reference.** The seam carries the two separately because they differ on the other targeted backend; here they coincide, and the app relies on that. Splitting them later would break every cross-entity reference silently.
|
||||
3. **A partial update must stay partial.** The app sends only changed fields. A move to replacing the resource would drop every field omitted, with no error.
|
||||
4. **Every participation must be readable by every user.** The participant count is the number of participations of an event, counted with no filtering. What is confidential is the **link between a participation and a person** — only a connection can read the profile a participation points at and recognise who it is — never the participation record itself. See need 1: this is the constraint the package does not currently let the app satisfy.
|
||||
5. **The count is never a stored, incremented field.** Reading a counter, adding one and writing it back derives each write from a read of the previous one and races itself through any data provider. This is measured, not theoretical, and it is why the app derives the count from the participation list at read time.
|
||||
6. **Changes must be pushed, not polled.** Neither the app nor its BDD suites has a polling loop, and adding one is ruled out by project doctrine. `liveMode: 'auto'` is mounted on that basis.
|
||||
7. **No process-global state that outlives a remount.** Two backends coexist in one page and the user switches between them at runtime; switching remounts Refine to discard the cache of the backend being left. Building this backend's providers must have no side effect that accumulates across switches — see friction 1, which is where this currently bites.
|
||||
8. **Each entity carries a declared visibility** — public, protected or private, per the product's confidentiality model — and it is the backend that honours it. Refine has no notion of it, so the intent is declared at the seam and enforcement is the backend's.
|
||||
|
||||
## Frictions
|
||||
|
||||
1. **`authProvider` installs a timer and a listener it never removes.** Construction registers a `setInterval` and a `visibilitychange` listener with no teardown. Festipod builds providers once per backend selection, so every switch to this backend would accumulate another pair — directly against constraint 7. Workaround carried: pass `appStatusCheckInterval: false` and call `getAppStatus()` on the app's own schedule. That works, but it means the package's own staleness handling is unusable here, and the only way to opt out is to opt out entirely. The re-baseline of 2026-10-12 pinned the `getAppStatus` shape (`GET /.well-known/app-status` → `{ upgradeNeeded }`), so the workaround is now implementable; wiring it is tracked in the handoff.
|
||||
2. **`getList` fetches the whole container on every call.** Filtering, sorting and pagination run in memory afterwards, so the cost of listing an event's participations grows with the user's total number of participations rather than with the event. No workaround; it is why a filter on a `getList` buys nothing beyond convenience.
|
||||
3. **A list subscription reports membership only.** A container channel delivers `Add`/`Remove`, not a member's own fields changing, so a list of events does not refresh when an event's title, place or time is edited. Watching each record instead would open one channel per row. The event list is exactly the screen where this is visible.
|
||||
4. **`types` takes precedence over `shapeTreeUri`, the opposite of what the configuration type documents.** The declared behaviour is that `types` is "only used as a fallback when `shapeTreeUri` is not set"; a resource setting both is in fact resolved from `types`. Establishing which one actually wins cost a read of the resolution path. Configuring exactly one avoids it, but the documentation should say which wins.
|
||||
5. **The member shape of an `apods:attendees` collection is not pinned anywhere Festipod may read at work time.** The reconstruction documents the container discovery, not what a membership record is: a bare actor URI, or an object carrying event/participant/status. The project reconnaissance records only that a join "adds the actor to the collection" and that members come back "as full objects for a reader who is allowed, and as URIs otherwise". The ActivityPods backend's participation binding is designed against that: a member maps to `{ id, userId, isConfirmed: true }` (membership IS the attendance), with `eventId` supplied by the reader — the event the collection was read from — because a member carries no event link. The first runtime read against the local platform (T04.g) must confirm or correct this mapping.
|
||||
|
||||
## Needs
|
||||
|
||||
1. **A read that is not scoped to the logged-in user's own Pod.** Every container resolution — in `getList`, in `create`, and in a list subscription — runs against the session's own WebID. So `getList('events')` returns only the events the current user created, and `getList('participations')` only their own participations.
|
||||
|
||||
This blocks the product, not a detail of it. Festipod exists to let a user **discover other people's public events** and see **how many people have signed up** to a meeting point. Through this provider the app can show neither: no other user's event is reachable, and the participant count of constraint 4 cannot be computed from participations it cannot read.
|
||||
|
||||
The request is modest in shape, because the capability already sits one layer down: the package's own TypeIndex discovery takes a WebID as a parameter, and notes that the public TypeIndex is readable without any special grant. What is missing is a way to reach it **through the data provider** — a way to scope a `getList` (and the matching list subscription) to a given WebID, or to a set of them. Festipod has no interest in reimplementing container resolution beside the provider; that would put the app back in the business the provider exists to hold.
|
||||
|
||||
2. **Releases that can be pinned, and a statement of what changes between them.** The package carries no git tags, so the only thing this repo can pin is a commit on a moving branch, and the only way to learn what an upgrade changed is to re-read the source. Tagged releases plus a note of what moved in each — in whatever form suits the project — would let a consumer upgrade deliberately instead of re-observing from scratch every time.
|
||||
@@ -0,0 +1,100 @@
|
||||
---
|
||||
type: brief
|
||||
summary: One persisted shape holding on BOTH backends — NextGraph (per-document encryption) and ActivityPods (WAC) — references rather than embedded content, no stored counter, with the acl:Append behaviour and the deduplication gap left open
|
||||
---
|
||||
|
||||
# Brief (2026-10-03) — One data shape for two rights models
|
||||
|
||||
Festipod puts all its persistence behind interchangeable Refine backends (the seam's own contract is in code, `src/shared/refine/providers/types.ts`). Two are targeted: NextGraph plus its polyfill, and `@activitypods/refine-providers`, whose reconstructed observation is pinned beside this leaf ([[contract_activitypods-providers]]). The question this brief answers: what structure survives both rights models unchanged.
|
||||
|
||||
## The primitives, side by side
|
||||
|
||||
| What is needed | ActivityPods | NextGraph |
|
||||
|---|---|---|
|
||||
| Unit of rights | LDP resource + WAC | **document** |
|
||||
| List attached to a resource | `as:Collection` attached by a predicate, `semapps:dereferenceItems` | a Set inside the document |
|
||||
| Let a third party contribute | `acl:Append`, or `apods:PostOutbox` to the recipient's inbox | `inbox.postToDocument` |
|
||||
| Grant "my connections" | `acl:agentGroup` + a WAC group | per-document sharing, once per connection |
|
||||
| Name without granting access | reference + WAC refuses the GET | reference carrying no key |
|
||||
| Revoke | `pod-permissions.remove` exists | not published |
|
||||
| Write offline | no (client-server) | yes (local-first) |
|
||||
|
||||
## The design rule that follows
|
||||
|
||||
**The common denominator is NextGraph**, whose model is the strict subset: no groups, no revocation, sharing wholesale per document. So structure for NextGraph and ActivityPods will serve that structure — more cheaply even, with groups. The converse does not hold: any structure that needs a server-side projection or a revocation is untenable on NextGraph.
|
||||
|
||||
## The structure
|
||||
|
||||
```
|
||||
Event own document, public, written by its creator
|
||||
└─ participations an attached list of REFERENCES (never embedded content)
|
||||
Participation own document, owned by THE PARTICIPANT
|
||||
├─ event → ref
|
||||
├─ active → boolean
|
||||
└─ participant → ref to the Profile
|
||||
Profile own document, protected
|
||||
```
|
||||
|
||||
## Why references and not embedded content — three facts
|
||||
|
||||
1. **Ownership.** The participation must be writable by its participant and by nobody else (withdrawal must be authoritative — [[caveat_participation-deletion]]). On NextGraph write access is granted per document, so the participation **must be** a document; embedded in the event it would be writable only by the event's creator.
|
||||
2. **Per-reader variation is impossible inside a single document on NextGraph**, because encryption and sharing are per document. It is obtained by **indirection**: the reference is always present, and what varies is the ability to **follow** it.
|
||||
3. [[rule_document-per-entity]] already says this, and says it for exactly this reason.
|
||||
|
||||
## The non-visibility mechanism differs — PO precision, 2026-10-03
|
||||
|
||||
On NextGraph the target is **undecipherable** for want of a key: nothing is absent, the reference itself is readable. On ActivityPods it is either inaccessible (WAC refuses the GET) or the reference is omitted by server-side projection. Both land on the same application state: **a reference one may or may not resolve.**
|
||||
|
||||
## Consequence on the domain types
|
||||
|
||||
The type models the NextGraph shape, where everything is present:
|
||||
|
||||
```ts
|
||||
interface FpParticipationData {
|
||||
id: string;
|
||||
event: string;
|
||||
active: boolean; // NOT optional — the participation is readable by everyone
|
||||
participant: string; // NOT optional — a reference, which names without granting access
|
||||
}
|
||||
```
|
||||
|
||||
Resolution alone may fail: `resolveParticipant(ref)` yields `FpUserData | undefined`, where `undefined` means "not a connection". ActivityPods' omission is a **degradation the binding absorbs**: it reports "a participation whose participant does not resolve" — the same state as "I do not hold the key".
|
||||
|
||||
An error made in session and corrected here: `active?` and `participant?` were proposed first, which lifted the ActivityPods adaptation into the common model.
|
||||
|
||||
## The count
|
||||
|
||||
**No stored counter, ever.** The number of participants is `participations.length`. `knownParticipants` is the participations whose participant resolves — a presence test, not an access check. Withdrawal is `active: false` on one's own document.
|
||||
|
||||
## The three invariants that make the app platform-indifferent
|
||||
|
||||
1. One document per entity; never embedded content.
|
||||
2. A reference may be unresolvable, and that is a **normal state** — neither an error nor an absence (the app shows "someone").
|
||||
3. The list is an **upper bound that converges**; never assume it is complete at the instant (already documented by [[caveat_participant-count-one-connection-lag]]).
|
||||
|
||||
## Open points
|
||||
|
||||
### NOT VERIFIED — does `acl:Append` on a collection let a third party insert an item without being able to remove one?
|
||||
|
||||
WAC mode enforcement lives in `@semapps/webacl`, absent from the ActivityPods checkout consulted and not installed. The mode is real and genuinely used (`tests/apps/example.app.js` declares `accessMode: ['acl:Read', 'acl:Append']`), but its behaviour on a SparqlPatch `INSERT DATA` / `DELETE DATA` was not observed.
|
||||
|
||||
**The structure does not depend on the answer.** If Append works that way, the participant appends directly and the count is immediate. Otherwise the route is `apods:PostOutbox` to the creator's inbox, whose app adds the reference — which is exactly the NextGraph shape. Only the latency changes.
|
||||
|
||||
### OPEN — deduplication: neither platform counts people, and the implementation here does not either
|
||||
|
||||
[[brief_2026-07-20_attendance-set-model]] requires that the creator tell two references from the *same* person apart from two references from different people, **without knowing who** — otherwise the count is not a count of people and a participant can inflate it. **The code here does not do that** (verified 2026-10-05). A different problem is solved, and solved well: `materializeAttendance` (`src/shared/data/registration.ts`) keys its active set by a **per-deposit** `uid` minted from time plus randomness, so a broker re-sync replaying the same deposits converges instead of double-counting — that is **replay idempotence**, not person identity. The count is therefore a count of **deposits**: the same person joining twice counts twice. Three further findings about the current implementation:
|
||||
|
||||
- **The plaintext `userId` travels in the deposit**, and the cancellation fallback depends on it — a leave without a known `regUid` cancels the earliest still-active join carrying that `userId`, so N leaves cancel N joins. The attendance-set brief's own change *"drop the `userId` from inbox deposits"* was never made, so the creator **does** know who and the anonymity property does not hold today. The privacy-preserving discriminator was never needed because the privacy was never implemented.
|
||||
- **Participations live in the protected scope**, not public, and withdrawal is still a deletion ([[caveat_participation-deletion]]) — so the target model is not in place either. The attendance-set brief's own status line, *model settled / implementation gated*, still holds.
|
||||
- **The gap was never raised through the declared channel.** The five frictions in this app's polyfill declaration (`polyfill-surface/usage_festipod.md`) cover inbox idempotence, the absence of a push on one's own deposit, the reactive read not seeing one's own write, the impossibility of resetting the test wallet, and a wrong published signature. None asks for a per-person discriminator.
|
||||
|
||||
So the divergence is not "NextGraph has an upstream gap while ActivityPods does not": **both count deposits rather than people.** ActivityPods could arbitrate server-side because the server sees everything; NextGraph awaits a primitive. And the gap sits in **neither Refine provider** — it is in the polyfill on one side and the server on the other, which is what matters when deciding what to contribute where. The upper bound remains the common fallback position, already accepted by the PO.
|
||||
|
||||
## Two ActivityPods constraints not to rediscover later
|
||||
|
||||
- **The Refine providers cover CRUD only.** Creating the collection, setting WAC, creating a group, posting to the outbox are not in the data provider, which does not implement Refine's `custom` either — so there is no escape hatch through Refine and those calls happen beside it.
|
||||
- **A custom ontology prefix must be registered at prefix.cc**, the Pod provider requiring every ontology to have a known prefix; this concerns our `fp:`. Shape trees must also sit at a public URL, one per entity, since they cannot reference other shapes — no sharing cascade.
|
||||
|
||||
## Reservation to keep alive
|
||||
|
||||
The reference to the profile is readable by everyone and therefore collectable: it is a **correlatable pseudonym, not anonymity**, and must never be presented as such. The reservation comes from the attendance-set-model brief, and the 2026-10-03 precision reactivates it.
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
type: brief
|
||||
summary: ActivityPods is the sole priority provider — everything that can goes through the Refine providers, a backend for actions on behalf of a user is to be added, the NextGraph+polyfill backend is suspended (temporary); reference material and open questions recorded 2026-10-09
|
||||
---
|
||||
|
||||
# Brief (2026-10-09) — ActivityPods first, and a backend for on-behalf actions
|
||||
|
||||
Directives recorded at the resumption of the hand-off (`.project/handoff/refine-provider-abstraction.md`), to anchor the ActivityPods integration workstream.
|
||||
|
||||
## Directives
|
||||
|
||||
1. **ActivityPods is the priority.** The Refine backend over NextGraph plus its polyfill is **suspended, temporarily** — it is not abandoned (the two-rights-models structure still targets both), but no work goes into it while ActivityPods is being wired.
|
||||
2. **Everything that can must go through `@activitypods/refine-providers`.** The Refine boundary rule from the hand-off applies: what Refine can express is a Refine resource; what it cannot (collection creation, WAC, groups, outbox posting) happens beside Refine, in the seam or in the new backend. Capability differences stay inside the provider layer, never in the app.
|
||||
3. **A backend must be added for actions "on behalf of a user".** Festipod needs server-side actions executed for the account of a user (the pattern used by the reference apps below). Its mechanism, packages and deployment shape are the first design question of this workstream.
|
||||
4. **Minimise new shapes — interoperability first (directive 2026-10-09).** A new shape is a last resort, not a default: anything an existing vocabulary already carries must reuse it. A MeetingPoint is therefore modelled as **an event within an event** — an `as/Event` distinguished by an **optional `parentEvent` reference** to its anchor event, not a new `fp:MeetingPoint` type. Whether an existing ActivityStreams predicate (e.g. `as:context`) can carry that link, or the `as/Event` shape must gain an optional property, is part of the survey. For Participation, the existing fediverse vocabulary must be surveyed before anything is created.
|
||||
|
||||
## Reference material (user-designated 2026-10-09)
|
||||
|
||||
Reading these at this stage is adoption-time reading, explicitly directed — the standing constraint against reading provider sources at work time is unaffected (`@activitypods/refine-providers` remains specified by its pinned reconstruction, `activitypods-providers/contract_activitypods-providers.md`).
|
||||
|
||||
- **Example apps** — `/home/sylvain/projects/reconnexion/welcometomyplace` and `/home/sylvain/projects/reconnexion/mutual-aid.app`: working integrations to mine for provider configuration, resources, and the on-behalf backend pattern.
|
||||
- **ActivityPods platform source** — `/home/sylvain/projects/activitypods/activitypods`; Semapps lives in a sibling folder.
|
||||
- **Shapes** — `/home/sylvain/projects/activitypods/shapes`: readily usable, and **evolvable by us** if Festipod's entities require it (that repo is ours to adapt).
|
||||
|
||||
## Constraints already on record (see the 2026-10-03 brief and the reconstruction)
|
||||
|
||||
- `fp:` must be registered at prefix.cc; shape trees must sit at public URLs, one per entity, no sharing cascade.
|
||||
- The Refine providers cover CRUD only; `custom` is not implemented — no escape hatch through Refine.
|
||||
- `getMany` silently drops failed fetches — it erases the "unreadable reference" state the model needs (a candidate contribution).
|
||||
- `acl:Append` third-party insert-without-remove behaviour is NOT VERIFIED; the structure does not depend on the answer.
|
||||
|
||||
## Reconnaissance answers (2026-10-09)
|
||||
|
||||
- **On-behalf mechanism — ANSWERED.** An app backend is an ActivityPub actor (`@activitypods/app` `AppService`, Moleculer); it signs pod requests as the app actor and the pod impersonates the user after checking registration + grants. Frontend calls use the Solid-OIDC dual-identity token instead. No unattended per-user backend token exists.
|
||||
- **Registration/Participation — production precedent found.** welcometomyplace persists NO registration resource: a join is an `as:Join` activity posted to the user's outbox, the backend service handles it and adds the actor to the event's `apods:attendees` collection (attached via `attachPredicate`, `PodCollectionsHandlerMixin`); a leave is `as:Leave`, same path in reverse. Membership is read via `pod-collections.getItems`. mutual-aid uses none of this (comments, likes, announces only).
|
||||
- **Event-in-event — feasible without touching any shape.** The `as/Event` and `as/Profile` SHACL shapes are open (no `sh:closed`), and the generic ActivityStreams vocabulary already defines `as:context`, `as:partOf`, `as:inReplyTo`. A meeting point is a plain `as/Event` carrying an `as:context` link to its parent event — the presence of the link is what distinguishes it, nothing else. Shapes are consumed for access needs and container setup, not server-side resource validation, so nothing breaks.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Participation topology — the one real divergence from the 2026-10-03 structure: welcometomyplace keeps the canonical list creator-side (an `apods:attendees` collection on the event, writes backend-mediated), where the two-rights-models structure makes each participation a participant-owned document. Both land on the same application state (a reference that may or may not resolve); the choice is who owns the write path. **Arbitration route decided 2026-10-09: the implementation plan evaluates both against the concrete screens (count, withdrawal, discovery, reference non-resolution) and submits an argued recommendation before anything is frozen.**
|
||||
- `as:Join` has a shape but no shape tree: if participations must be addressable as resources in access needs, adding `shapetrees/as/Join.ttl` for an already-existing AS type is the minimal move — or the collection pattern makes it unnecessary.
|
||||
- The backend's deployment shape (service alongside the app? a platform app?).
|
||||
- Whether the four pointed-to provider contributions (from the hand-off) are still the right set once the backend exists — some may move from "contribution to the providers" to "our backend's job".
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
type: cookbook
|
||||
summary: Bring up a local ActivityPods pod provider for Festipod development — ports, the reproducible signup → login → create-as/Event flow, the fresh-pod container behaviour, teardown
|
||||
---
|
||||
|
||||
# Cookbook — Local ActivityPods platform
|
||||
|
||||
Platform checkout: `/home/sylvain/projects/activitypods/activitypods` (never modify it; verified pristine 2026-10-09). Everything runs from `pod-provider/`.
|
||||
|
||||
## Ports
|
||||
|
||||
pod provider 3000 (OpenID + `/auth` signup/login), Festipod backend 3003, Festipod app 4000, fuseki 3030 (admin/admin), mailcatcher 1080, redis 6390, arena 4567, tripleadmin 3033. The pod provider frontend owns :5000 — never run it while testing Festipod.
|
||||
|
||||
## Bring up
|
||||
|
||||
1. Infra: `cd pod-provider && docker compose up -d`. If host port 6379 is already taken, add an override file publishing redis on another port (6390 was used) and pass `SEMAPPS_QUEUE_SERVICE_URL=redis://localhost:6390/1` and `SEMAPPS_REDIS_OIDC_PROVIDER_URL=redis://localhost:6390/2` when starting the backend — dotenv-flow does not override the process environment.
|
||||
2. Install once: `cd backend && COREPACK_ENABLE_AUTO_PIN=0 corepack yarn install --frozen-lockfile`, same in `frontend/`. Keep `COREPACK_ENABLE_AUTO_PIN=0`: corepack auto-pin rewrites `packageManager` in package.json. Note: the pod-provider README says `yarn start` for the frontend; only the `dev` script exists — use `BROWSER=none corepack yarn dev`.
|
||||
3. Backend: `cd backend && corepack yarn start`. Wait for "ServiceBroker with ... service(s) started successfully" (~11 s).
|
||||
4. Frontend: `cd frontend && BROWSER=none corepack yarn dev`. Verify: `GET http://localhost:5000/` → 200 and `GET http://localhost:3000/.well-known/config.js` → 200.
|
||||
5. Festipod stack (this repo root): `cp backend/.env.example backend/.env` before `docker compose up`. The compose reads `backend/.env` as an optional `env_file`; on a fresh clone the file is gitignored and absent, so this copy is what feeds the backend its ActivityPods settings. With the coherent layout the app is served on :4000 and the Festipod backend on :3003 — keep the pod provider frontend (which owns :5000) stopped while testing Festipod. To serve the app manually (`bun run start` on :4000) ALSO copy the root env: `cp .env.example .env` — without `VITE_BACKEND_URL` the config route serves an empty backend URL and the app deliberately falls back to the local demo (seeded data, no login).
|
||||
|
||||
## Account and resource flow (all VERIFIED 2026-10-09)
|
||||
|
||||
- Signup: `POST http://localhost:3000/auth/signup`, `Content-Type: application/json` (NOT JSON-LD), body `{"username":"alice","email":"alice@test.com","password":"Test1test","name":"Alice","schema:knowsLanguage":"en"}` → 200 `{"token":"...","webId":"http://localhost:3000/alice","newUser":true}` (~10 s, pod-creation hooks).
|
||||
- Login: `POST http://localhost:3000/auth/login`, same content type, `{"username":"alice","password":"Test1test"}` → 200 with token; wrong password → 401.
|
||||
- Read the actor: `GET http://localhost:3000/alice`, `Authorization: Bearer <token>`, `Accept: application/ld+json` → 200; gives `pim:storage` (`http://localhost:3000/alice/data`).
|
||||
- Create an event: `POST <storage>`, Bearer + `Content-Type: application/ld+json`, body `{"@context":"https://activitypods.org/context.json","type":"Event","name":"...","startTime":"...","endTime":"..."}` → 201 + `Location`.
|
||||
- Re-read `Location` with Bearer → 200 full JSON-LD; without token → 403 (WAC: private by default).
|
||||
|
||||
## Fresh-pod behaviour
|
||||
|
||||
On a fresh pod, an `as:Event` POST lands at the pod root (`/alice/data/<uuid>`): typed containers such as `/alice/data/as/event` exist only after a user installs an app whose access needs declare that shape tree. Do not expect typed containers before app registration.
|
||||
|
||||
## Teardown
|
||||
|
||||
`pkill -f moleculer-runner; pkill -f react-scripts`, then `docker compose down` in `pod-provider/`. Data persists in `pod-provider/data/` (gitignored); delete that directory to fully reset.
|
||||
|
||||
Current stack test account: `alice` / `Test1test` on localhost:3000.
|
||||
+34
-5
@@ -1,7 +1,10 @@
|
||||
# Inter-repo contracts. Festipod is a CONSUMER only: it publishes no interface of its own,
|
||||
# and it consumes two — the SDK surface `@ng-eventually/polyfill` engages toward the
|
||||
# applications built on it, and the indexing layer `ng-helpers` engages toward the
|
||||
# applications that need to make things findable.
|
||||
# Inter-repo contracts. Festipod publishes no interface of its own — what it lists under
|
||||
# `publish:` is its own consumer-side DECLARATION, the channel a provider pulls to learn
|
||||
# what this app calls, what it needs and what blocks it. It consumes three — the SDK
|
||||
# surface `@ng-eventually/polyfill` engages toward the applications built on it, the
|
||||
# indexing layer `ng-helpers` engages toward the applications that need to make things
|
||||
# findable, and the Refine data providers `@activitypods/refine-providers`, which engage
|
||||
# nothing at all.
|
||||
#
|
||||
# The pulled copy under `into:` IS the specification Festipod codes against. An agent
|
||||
# working here reads that copy and never opens the provider's own source: a gap is raised
|
||||
@@ -9,7 +12,7 @@
|
||||
#
|
||||
# Each interface gets its own FOLDER inside the concept that owns it, holding the pulled
|
||||
# engagement and — once Festipod actually consumes the interface — the `usage_festipod.md`
|
||||
# declaration beside it. Both interfaces land in `data-layer`: it is the concept that owns
|
||||
# declaration beside it. All three land in `data-layer`: it is the concept that owns
|
||||
# how Festipod uses an external data surface, including the machinery behind discovery
|
||||
# (`functional-domain` owns the product intent of discovery and explicitly delegates its
|
||||
# technical how to the data SDK).
|
||||
@@ -19,6 +22,14 @@
|
||||
# access to a local checkout lives in `.project/contracts.local.yaml`, which is never
|
||||
# committed.
|
||||
|
||||
publish:
|
||||
# Both declarations are named `usage_festipod.md`, so the keys are qualified by their
|
||||
# interface — publish keys must stay unique, and the bare subject no longer says which.
|
||||
# Listing a declaration here is what lets its provider re-find it with `list --from`
|
||||
# after it moves; it sends nothing on its own.
|
||||
polyfill-surface--festipod: concepts/data-layer/polyfill-surface/usage_festipod.md
|
||||
activitypods-providers--festipod: concepts/data-layer/activitypods-providers/usage_festipod.md
|
||||
|
||||
consume:
|
||||
- contract: polyfill-surface
|
||||
into: concepts/data-layer/polyfill-surface/
|
||||
@@ -43,3 +54,21 @@ consume:
|
||||
# Pinned on the TAG, never on a branch: a branch moves under us and the pin would stop
|
||||
# naming a state anyone can go back to. Re-pin to the next tag at each upgrade.
|
||||
ref: v1.0.1
|
||||
|
||||
- contract: activitypods-providers
|
||||
into: concepts/data-layer/activitypods-providers/
|
||||
# `@activitypods/refine-providers` publishes NO engagement: no contract document, no
|
||||
# stated change policy, not even a git tag. So this entry is `type: reconstruct` — the
|
||||
# one bounded crossing this protocol allows, made once at adoption time: the leaf under
|
||||
# `into:` is OUR observation of that package, authored here and pinned to the commit it
|
||||
# was read at. It is not a promise anyone made us, and every upgrade has to re-observe
|
||||
# it. Once reconstructed, that leaf IS the specification this app codes against.
|
||||
type: reconstruct
|
||||
# Canonical identity: the package's git remote plus the repo-relative folder the
|
||||
# observation was made from. Per-developer access to a checkout of it lives in
|
||||
# `.project/contracts.local.yaml`, which is never committed.
|
||||
reconstructFrom: https://github.com/activitypods/refine-providers.git/src
|
||||
# The branch the observation was read from. A tag would be the better pin, but this
|
||||
# repository publishes none — the resolver turns this into the commit actually read,
|
||||
# which is what `built_version` records.
|
||||
ref: master
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
---
|
||||
subject: Refine as Festipod's provider-swap abstraction layer
|
||||
created: 2026-10-08
|
||||
status: open
|
||||
---
|
||||
|
||||
# Refine as the provider-swap abstraction layer
|
||||
|
||||
## Where the work lives
|
||||
|
||||
Branch **`refine-backends`**, worktree `/home/sylvain/projects/festipod/festipod/.claude/worktrees/refine-backends`, pushed to origin; the tip records the T04 tree close plus the adversarial-review round — `## Current gate state` below names the states by date and tip.
|
||||
|
||||
It is based on **`ng-eventually`** at `c7cda38` — the most advanced branch, and the reason this branch exists. An earlier attempt, **`festipod-refine`** (tip `352d1f1`, worktree `.claude/worktrees/festipod-64`), was built on `main`, which turned out to be far behind; the Refine work was transferred here and that branch is now only an archive. It holds the **only** worked implementation of a `@ng-org/refine-providers` backend (`src/shared/refine/providers/nextgraph.ts`, in its history up to the commit that withdrew it) — the reference for the shape of the next backend.
|
||||
|
||||
The main checkout `/home/sylvain/projects/festipod/festipod` is itself on `ng-eventually`. Do not modify it. Note that the concept hook reads it, so it announces leaves that may not exist in whichever worktree you are in.
|
||||
|
||||
## The goal
|
||||
|
||||
Put all of Festipod's persistence behind Refine so that several **provider sets** can serve the same app. **ActivityPods is the default and the sole priority** — directive of 2026-10-09 (recorded in `data-layer/brief_2026-10-09_activitypods-first-backend`): everything that can goes through `@activitypods/refine-providers`, and a **backend for actions on behalf of a user** is to be added. A Refine provider over NextGraph plus its polyfill remains the declared second target, but its development is **suspended, temporarily** — no work goes into it while ActivityPods is wired.
|
||||
|
||||
## Directives of 2026-10-09 (resumption) — and first reconnaissance
|
||||
|
||||
Recorded in `data-layer/brief_2026-10-09_activitypods-first-backend`; the essentials and what the reconnaissance (three read-only passes over the user-designated reference repos) established:
|
||||
|
||||
- **The on-behalf mechanism is mapped.** An app backend IS an ActivityPub actor (`@activitypods/app` `AppService`, Moleculer). It signs pod requests with HTTP signatures as the app actor; the pod provider checks the app's registration and grants, then impersonates the user (`pod-provider/backend/middlewares/app-control.js`). Services call `pod-outbox.post`, `pod-resources.*`, `pod-collections.*`, `pod-wac-groups.*`, `pod-permissions.*` with an `actorUri`. Frontend calls use the Solid-OIDC dual-identity token instead (`webId` = app client_id, `impersonatedUser` = user). Reference implementations: `welcometomyplace/backend/services/registration.service.js`, `attendees-matcher.service.js`; both example apps run the same stack (`@activitypods/app` 2.3 + `@semapps/*` 1.2, Moleculer backend + Vite/React/Refine frontend, Docker-deployed).
|
||||
- **The refine-providers package is NOT in the platform monorepo** — it lives at `/home/sylvain/projects/activitypods/refine-providers` (a `/home/sylvain/projects/nextgraph/activitypodes-refine-providers` checkout also exists; the pinned reconstruction remains the work-time specification).
|
||||
- **Shapes**: 15 shape trees published at `https://shapes.activitypods.org` from `/home/sylvain/projects/activitypods/shapes` (repo evolvable by us). The 2026-10-09 directives **amended the first mapping proposal — minimise new shapes, interoperability first**: a MeetingPoint is a plain `as/Event` carrying an `as:context` link to its parent event (event within an event; the `as/Event` shape is open — no `sh:closed` — so nothing breaks and no new shape is needed). For Participation the existing vocabulary was surveyed: see the registration precedent in the next bullet.
|
||||
- **Registration/Participation — production precedent found.** welcometomyplace persists no registration resource: join = `as:Join` activity in the user's outbox, handled by the backend service which adds the actor to the event's `apods:attendees` collection (`PodCollectionsHandlerMixin`, `attachPredicate`); leave = `as:Leave`; reads via `pod-collections.getItems`. The one open design question this raises — collection creator-side (this pattern) vs participant-owned participation documents (the 2026-10-03 structure) — is recorded in the brief, to arbitrate before implementation.
|
||||
- **No unattended per-user backend token exists** on the platform: server-side per-user actions ride the app-actor signature + grants; the platform-idiomatic channel for user-facing reactions is the app outbox → user inbox (notifications style).
|
||||
|
||||
## What exists, and what deliberately does not
|
||||
|
||||
In `src/shared/refine/`:
|
||||
|
||||
- `variant.ts` + `variant.test.ts` — pure `resolveVariant(pathname, known)`: which provider set the URL selects. 8 tests covering the case space, including the one true today (empty registry → every URL is the default).
|
||||
- `providers/types.ts` — the contract a backend must satisfy (`RefineBackend`, `RecordBinding`), each field derived from a measured encounter rather than anticipated.
|
||||
- `providers/index.ts` — the registry. **EMPTY on purpose**: no backend is implemented yet.
|
||||
- `RefineProvider.tsx` — mounts `<Refine>` over the backend the URL named.
|
||||
|
||||
**None of it is wired.** Nothing imports `RefineProvider`; `FestipodDataContext` on this base is untouched and still talks to the polyfill directly. Wiring it means rewriting that context against this base's much-evolved version — that is the step everything else waits on.
|
||||
|
||||
In `.project/`:
|
||||
|
||||
- `concepts/data-layer/activitypods-providers/contract_activitypods-providers.md` — a **reconstruction**, not a pull: that package publishes no engagement, so this is our own observation, pinned (`built_version: 66616484b9…`). It is the specification to code against.
|
||||
- `concepts/data-layer/activitypods-providers/usage_festipod.md` — our declaration toward them. Its Frictions/Needs are the channel to the provider.
|
||||
- `concepts/data-layer/brief_2026-10-03_structure-for-two-rights-models.md` — the data-shape design, below.
|
||||
- `concepts/data-layer/brief_2026-10-09_activitypods-first-backend.md` — the 2026-10-09 directives (ActivityPods priority, on-behalf backend, NextGraph suspended) and the reference material; the brief this hand-off's resumption added.
|
||||
- `handoff/refine-provider-abstraction.md` — this document.
|
||||
- `contracts.yaml` — `publish:` (both declarations, keys qualified by interface) and three `consume:` entries (polyfill-surface, indexing-layer, activitypods-providers).
|
||||
- `contracts.local.yaml` — gitignored, per-worktree. Must exist for `contracts.py check` to resolve the ActivityPods entry.
|
||||
|
||||
## Decisions, and why
|
||||
|
||||
**One app variant per provider set, selected by a URL path prefix.** The default at the root, another behind a prefix naming it; switching is a plain link and costs a full page reload. The reload is the mechanism, not a cost: it discards the previous backend's Refine cache and session with no code to undo them, and losing access to the left backend's data is accepted. An earlier plan for in-session hot switching was dropped. The deep reason: **the sign-in experience is not abstractable** — the NextGraph SDK mounts its own full-screen barrier and owns the whole wallet experience (`src/app/AuthGate.tsx` renders no access screen), while a Solid backend hands the app an OIDC flow it must render itself (Pod-provider picker, callback route). One confiscates the screen, the other delegates a flow.
|
||||
|
||||
The prefix **is** the backend's name, so a backend must never be named after a top-level route (`events`, `profile`, `home`, `settings`, `users`).
|
||||
|
||||
**The router does not yet strip the prefix, deliberately.** `parsePath`/`navigate` in `src/app/router.tsx` are a shared primitive every screen depends on; the suites that would prove a change there are not run in this worktree; and while one backend is registered no URL carries a prefix, so nothing is broken. That half lands with the second backend, when it becomes testable.
|
||||
|
||||
**The data shape: references, not embedded content.** Three levels — Event (own document, public, written by its creator, carrying an attached list of *references*); Participation (own document, owned by **the participant**, carrying `event`, `active`, `participant`); Profile (own document, protected). Three facts force it: the participation must be writable by its participant and nobody else, and NextGraph grants write per document; per-reader variation is impossible inside one document where encryption is per document, so it is obtained by **indirection** (the reference is always present, the ability to *follow* it varies); and `rule_document-per-entity` already says this for that reason.
|
||||
|
||||
The non-visibility mechanism differs and both land on the same application state — *a reference one may or may not resolve*. On NextGraph the target is **undecipherable** for want of a key (nothing is absent). On ActivityPods it is refused by WAC, or omitted by server-side projection. So the domain type models the NextGraph shape, where everything is present: `active` and `participant` are **not** optional; only resolution fails. (Proposing them optional was an error — it lifted an ActivityPods adaptation into the common model.)
|
||||
|
||||
**No stored counter, ever.** The participant count is `participations.length`. `knownParticipants` is the participations whose participant resolves — a presence test, not an access check. Withdrawal is `active: false` on one's own document.
|
||||
|
||||
**NextGraph is the common denominator.** Its model is the strict subset: no WAC groups, no revocation, sharing wholesale per document, but writes work offline. Structure for it and ActivityPods serves the structure, more cheaply even. The converse fails: anything needing a server projection or a revocation is untenable on NextGraph.
|
||||
|
||||
**The Refine boundary rule.** Refine's `DataProvider` is flat CRUD (`getList`, `getMany?`, `getOne`, `create`, `createMany?`, `update`, `updateMany?`, `deleteOne`, `deleteMany?`, `getApiUrl`, `custom?`) — **no relation or join concept at all**, and no `getManyReference`. Everything that makes Festipod social (append to someone else's collection, grant/revoke read, discovery, offline queueing) is outside its vocabulary. Since cache, live subscription and optimistic update are bought **per resource**, the rule is: *anything the app displays and that changes must be a Refine resource; anything one-shot and invisible may live in the seam.* A write passed beside Refine does not invalidate its cache, so the list would not refresh.
|
||||
|
||||
Refine does support **several data providers at once** (`Resource.dataProviderName`, `useList({dataProviderName})`). Deliberately unused for switching, but it is the tool for reading one entity from another backend (discovery from a shared index) or for migration.
|
||||
|
||||
**The seam should shrink.** `types.ts` was written from measurements against one raw provider that could not be changed. Since we can contribute to the providers, several of its fields are symptoms rather than requirements: `updateReplaces` + `toPatch` should vanish (a provider exposes one semantic), and `recordKey` vs `domainId` should collapse to `id` (ActivityPods already does). `toDomain`/`toValues` shrink to vocabulary mapping; `visibility` and `resource` stay — visibility is a product declaration, not a transport fact. **The measure of success is how small `RecordBinding` gets.** Capability differences belong inside the provider, never in the app.
|
||||
|
||||
## What is known about `@activitypods/refine-providers`
|
||||
|
||||
The reconstruction is the specification. Its source was read **once, deliberately, at adoption time** (that is what a reconstruction is); it is not to be read again at work time — a gap goes into the declaration's Frictions/Needs. Checkouts, if ever needed again: the package at `/home/sylvain/projects/nextgraph/activitypodes-refine-providers`, the platform at `/home/sylvain/projects/activitypods/activitypods`.
|
||||
|
||||
- A record's `id` is the LDP resource URI, and it is the **only** identifier — `getOne`/`update`/`deleteOne` use it directly as the URL. The seam's two identifiers collapse into one here.
|
||||
- `update` fetches the current resource, strips `id`/`@context`, and PUTs `{...current, ...variables}` — so **a partial write is safe**, the merge being **shallow**.
|
||||
- `getList` resolves the resource's container from the **session's own WebID** (public Solid TypeIndex, best-effort private), so it returns only what the current user holds. `getOne`/`getMany` fetch a URI directly and so **do** cross Pods — they just need the URI.
|
||||
- No server-side filtering, sorting or pagination: the whole container is fetched, then filtered in memory.
|
||||
- **`getMany` silently drops failed fetches** (`allSettled` then filter) — it erases exactly the "unreadable reference" state the model needs.
|
||||
- Module-level caches (shape trees, type registrations per WebID, subscription endpoints) are **never invalidated**.
|
||||
- `authProvider` installs a `setInterval` and a `visibilitychange` listener at construction with **no teardown**; the only opt-out is `appStatusCheckInterval: false`.
|
||||
- Live updates are real push (`notify:WebSocketChannel2023`), but a **list** subscription reports membership only (`Add`/`Remove`), never a member's field change.
|
||||
- `types` takes precedence over `shapeTreeUri` when both are set — the **opposite** of what the configuration type documents.
|
||||
- `custom` is not implemented, so there is **no escape hatch through Refine** for non-CRUD operations.
|
||||
- Collections are POSTable LDP resources (`/as/collection`) with `semapps:dereferenceItems` — *"all items will be dereferenced (if permissions allow)"*. That is how `event.participations` can come back as full objects for a reader who is allowed, and as URIs otherwise, in one read. Items are added/removed by SparqlPatch on `as:items`.
|
||||
- `acl:Append` exists among the WAC modes and is really used in app access needs. **Whether it lets a third party insert an item without being able to remove one is NOT VERIFIED** — enforcement lives in `@semapps/webacl`, absent from the checkout. The structure does not depend on the answer: if it works that way the participant appends directly; otherwise the route is `apods:PostOutbox` to the owner's inbox, which is exactly the NextGraph shape, and only latency changes.
|
||||
- An app declares `accessNeeds` (`shapeTreeUri` + `accessMode`) plus special needs: `apods:CreateCollection`, `apods:CreateWacGroup`, `apods:PostOutbox`, `apods:QuerySparqlEndpoint`, `apods:UpdateWebId`, inbox/outbox reads.
|
||||
- **Backend-less apps are supported**: `onlineBackend` is false only when the app description document at the `clientId` URI is not fetchable. A static file suffices.
|
||||
- A custom ontology prefix **must be registered at prefix.cc** (the Pod provider requires every ontology to have a known prefix) — this concerns Festipod's `fp:`. Shape trees must sit at a public URL, one per entity, and **cannot reference other shapes** (no sharing cascade).
|
||||
|
||||
**Four contributions this points to** (we may contribute to these providers): `getMany` must signal the unreadable instead of swallowing it; an append path into a collection one does not own; collection support as a resource with `dereferenceItems`; and a teardown for the auth provider's timer.
|
||||
|
||||
## The deduplication question — answered, and still open
|
||||
|
||||
The attendance model (`brief_2026-07-20_attendance-set-model`, PO 2026-07-27) requires the creator to tell two references from the *same* person apart from two from different people **without knowing who**, or the count is not a count of people and a participant can inflate it.
|
||||
|
||||
**Checked on this base: not solved.** A different problem is, and well. `materializeAttendance` (`src/shared/data/registration.ts`) keys its active set by a **per-deposit** `uid` minted from time plus randomness, so a broker re-sync replaying the same deposits converges — that is **replay idempotence**, not person identity. The count counts **deposits**: the same person joining twice counts twice. Further: the **plaintext `userId` travels in the deposit** and the cancellation fallback depends on it (a leave without a known `regUid` cancels the earliest still-active join for that `userId`), so the creator *does* know who and the anonymity the model rests on does not hold. Participations are in the **protected** scope rather than public, and withdrawal is still a deletion. And the gap was **never raised** through the channel meant for it.
|
||||
|
||||
So both platforms count deposits rather than people, and the gap sits in **neither Refine provider** — polyfill on one side, server on the other. ActivityPods could arbitrate server-side because the server sees everything.
|
||||
|
||||
## Problems hit, and how they turned out
|
||||
|
||||
- **Two false claims were written into durable records and corrected.** First, that the participant count needs an inbox and that a CRUD-only backend therefore could not carry the model — wrong: every participation is readable by everyone, only the link to a person is protected, so counting is ordinary CRUD. Second, a declared "need" for a WebID filter on `getList` — Festipod does not need it: `getOne`/`getMany` already cross Pods, the participations arrive with the event, and discovery is an **index** problem (already decided on this base: `decision_2026-08-17_discovery-through-a-shared-index`), not a provider one. Both corrections are in the files.
|
||||
- **`__FESTIPOD_BACKEND__` was a lie in a comment** claiming `build.ts` defined it. Nothing did, and nothing could: this project serves from `src/` in production too (`bun run start`), so `build.ts`'s `define` block never reaches what runs (`tech-stack`, `knowledge_build-pipeline`). The runtime mechanism this base already uses instead is a config route — `/festipod-config.json` served by `src/index.ts`, read by `src/app/frontend.tsx` *before* it imports `App`. Replaced by the URL read.
|
||||
- **Three separate times the answer was already on `ng-eventually`** (the runtime config route, the delegation of wallet access to the SDK, the dedup implementation). That is what triggered rebasing onto it.
|
||||
- **The migration was delegated to a subagent** and verified independently afterwards: the five transferred files are byte-identical, the manifest merge preserved both pre-existing entries, and the gates were re-run by hand. Its one worry — that a missing local `node_modules` had made the baseline incomparable — was **unfounded** (pnpm had installed the full tree locally; 22 entries against the main checkout's 21). Its debt block was better than the specification it was given: it caught the `__FESTIPOD_BACKEND__` consequence and two branch-relative phrasings.
|
||||
- **Moving base falsified three claims in the transferred code**, all corrected: the inbox the count comment points at is local here; the worked provider implementation is not in this branch's ancestry; and the `MeetingPoint` SHEX shape **does** exist here, so its binding stopped being optional (a backend that cannot carry one of the four entities cannot serve the app).
|
||||
|
||||
## Current gate state (2026-10-10, adversarial-review round closed, tip = the commit recording this state)
|
||||
|
||||
Verified by the orchestrator's own runs after the round's fixes: `bunx tsc --noEmit` exit 0 ; `bun test` 96 pass / 0 fail ; the UI cucumber layer 125 scenarios / 0 failed (54 fully implemented + 71 `Scénario non implémenté` placeholders that carry a skipped step by design) ; smoke `@e2e-activitypods` 3/3 against the live local stack (pod provider :3000, festipod backend :3003, app served on an ephemeral port) ; `bun run build.ts` ok ; `contracts.py check --integrity-only` clean.
|
||||
|
||||
The round: three adversarial passes (auth & security, data seam & backend, gates & ops); every confirmed finding was fixed in the commit recording this state. Fix themes — auth: `redirect` param validated (same-origin leading-slash only), callback re-entry guarded against StrictMode double-exchange, stale `?code=` URLs degrade to the picker instead of the fatal screen, `register_app` without a session falls back to the picker (was a permanent blank screen), ActivityPods logout wired through the provider, deep links survive login, clientId trailing slash normalized. Data fidelity: the meeting point's host is read from the Pod-stamped `dc:creator` (the `organizer` term is absent from the platform context and never round-tripped, so the Join lost its addressee), `location`/`city` are written as inline named nodes (`as:location` is `@type: @id`; a free-text string resolved into a garbage IRI), record ids are URL-encoded (absolute IRIs broke the router's `/events/:id` parse), participant resolution is keyed by actor URI (profiles were stored under their document URI and never matched), one delayed re-invalidation reconciles the count after join/leave, a Join/Leave without addressee throws instead of posting to no one, anonymous placeholder rows render on the event detail screen. Backend & ops: the attendees collection gets a public `acl:Read` grant in `after.createAndAttach` (the count is readable by everyone; anonymity lives in the profiles' own WAC), Join/Leave handlers retry while the collection is still attaching, `tsx` promoted to dependencies so the `--prod` image starts, compose `env_file` made optional with fuseki/redis services added and the frontend published on :4000 (the SEMAPPS_FRONT_URL default — fixes the shipped redirect_uri mismatch; :5000 belongs to the pod provider frontend and is deliberately avoided), `test:data` removed (zero matching scenarios since the suspension — a vacuous green), `strict: true` on both cucumber configs.
|
||||
|
||||
`contracts.py check` also reports, pre-existing: `polyfill-surface: provider moved: a33fb8a214 → a8d53010c2` — the pulled copy is behind the pinned ref.
|
||||
|
||||
## Open points
|
||||
|
||||
- Adding `## Needs` to the polyfill declaration is where the per-person discriminator belongs. It is **outward-facing** (the provider reads it on its next pull), so it awaits the user's go.
|
||||
- `acl:Append` behaviour, as above.
|
||||
- The router's prefix stripping, with the second backend.
|
||||
- The four 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. Known remainder: a revoked app is not detected client-side (getAppStatus wiring — now implementable, `{upgradeNeeded}` shape pinned). 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()` is parked: the pinned contract documents the method but not the shape of the `AppStatus` result, so "stale" is not decidable without guessing. Belongs in the declaration's Frictions/Needs when the outward channel opens.
|
||||
- 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).
|
||||
- The attendees collection is publicly readable by decision (count for everyone; member URIs are not secret; anonymity lives in the profiles' WAC). Revisit if a product rule ever hides membership.
|
||||
- The dedup defi (same event + same place + same time) is confirmed absent across the seam; the 2026-10-03 brief marks it an open challenge — a product call, not a regression.
|
||||
- The catalog service's discovery index is process memory: a backend restart empties it while the Pods still hold the events. Latent until the cross-pod catalog provider lands.
|
||||
- `src/modules/home/**` screens still build raw `/events/${id}` paths; `parsePath` tolerates them, but they should adopt `eventPath`/`userPath` next time they are touched.
|
||||
- The dockerized path is validated by `docker compose config` only — the backend image has never been built or started by the gates; one manual operator run is owed.
|
||||
|
||||
## Standing constraints
|
||||
|
||||
- A pulled or reconstructed contract **is** the specification. Never read a provider's source at work time; a gap goes into the declaration's Frictions/Needs and is signalled out of band (content lives in the document). Never describe in this repo how the data layer is implemented.
|
||||
- **No attribution to Claude** anywhere in produced artifacts — commits, PRs, docs, comments. This overrides harness defaults.
|
||||
- Project documentation in **English**, never hard-wrapped (one line per paragraph or list item). Conversation with the user in **French**.
|
||||
- **No effort or timeline estimates**, anywhere.
|
||||
- Package installation goes through **pnpm**, never `bun install`; Bun-first otherwise.
|
||||
- Project facts are documented through `/concept document`, not written freehand into `.project/`.
|
||||
- Commit freely on a branch; push at validated states without being asked (Gitea SSH needs the sandbox disabled).
|
||||
- Never bare `git stash` — the stack is shared across worktrees and sessions.
|
||||
- `pre-push` blocks while any `_debt.md` exists.
|
||||
@@ -12,7 +12,7 @@ Web app mobile-first où les utilisateurs créent des **points de rencontre** qu
|
||||
src/screens/index.ts # Registre d'écrans (utilisé par Storybook)
|
||||
```
|
||||
- **Un module n'importe QUE depuis `shared/` — jamais d'un autre module.** C'est l'invariant qui rend l'archi réelle.
|
||||
- **Bun-first** : `bun` / `bun install` / `bun test` / `bun build`, jamais node/npm/vite/jest. `bun run dev` (port 3000).
|
||||
- **Bun-first** : `bun` / `bun install` / `bun test` / `bun build`, jamais node/npm/vite/jest. `bun run dev` (portless, port dynamique ; défaut servi par `bun run start` : 4000).
|
||||
|
||||
## Frontière SDK
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
SEMAPPS_APP_NAME=Festipod
|
||||
SEMAPPS_APP_DESCRIPTION=Points de rencontre sur les événements publics
|
||||
SEMAPPS_APP_LANG=en,fr
|
||||
SEMAPPS_HOME_URL=http://localhost:3003
|
||||
SEMAPPS_FRONT_URL=http://localhost:4000
|
||||
SEMAPPS_SHAPE_REPOSITORY_URL=https://shapes.activitypods.org/
|
||||
SEMAPPS_PORT=3003
|
||||
SEMAPPS_SPARQL_ENDPOINT=http://localhost:3030/
|
||||
SEMAPPS_MAIN_DATASET=settings
|
||||
SEMAPPS_JENA_USER=admin
|
||||
SEMAPPS_JENA_PASSWORD=admin
|
||||
SEMAPPS_REDIS_CACHE_URL=redis://localhost:6390/3
|
||||
SEMAPPS_QUEUE_SERVICE_URL=redis://localhost:6390/4
|
||||
SEMAPPS_AUTH_ACCOUNTS_DATASET_NAME=settings
|
||||
@@ -0,0 +1,25 @@
|
||||
FROM node:22-slim
|
||||
|
||||
# Install pnpm and build tools required for native modules (sharp, bcrypt).
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
python3 \
|
||||
make \
|
||||
g++ \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& corepack enable && corepack prepare pnpm@10.26.0 --activate
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies first to leverage Docker layer caching.
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile --prod
|
||||
|
||||
# Copy application code.
|
||||
COPY config ./config
|
||||
COPY moleculer.config.js ./
|
||||
COPY services ./services
|
||||
|
||||
EXPOSE 3003
|
||||
|
||||
CMD ["pnpm", "start"]
|
||||
@@ -0,0 +1,18 @@
|
||||
require('dotenv-flow').config();
|
||||
|
||||
module.exports = {
|
||||
APP_NAME: process.env.SEMAPPS_APP_NAME,
|
||||
APP_DESCRIPTION: process.env.SEMAPPS_APP_DESCRIPTION,
|
||||
APP_LANG: process.env.SEMAPPS_APP_LANG,
|
||||
HOME_URL: process.env.SEMAPPS_HOME_URL,
|
||||
FRONT_URL: process.env.SEMAPPS_FRONT_URL,
|
||||
SHAPE_REPOSITORY_URL: process.env.SEMAPPS_SHAPE_REPOSITORY_URL,
|
||||
PORT: process.env.SEMAPPS_PORT,
|
||||
SPARQL_ENDPOINT: process.env.SEMAPPS_SPARQL_ENDPOINT,
|
||||
MAIN_DATASET: process.env.SEMAPPS_MAIN_DATASET,
|
||||
JENA_USER: process.env.SEMAPPS_JENA_USER,
|
||||
JENA_PASSWORD: process.env.SEMAPPS_JENA_PASSWORD,
|
||||
REDIS_CACHE_URL: process.env.SEMAPPS_REDIS_CACHE_URL,
|
||||
QUEUE_SERVICE_URL: process.env.SEMAPPS_QUEUE_SERVICE_URL,
|
||||
AUTH_ACCOUNTS_DATASET_NAME: process.env.SEMAPPS_AUTH_ACCOUNTS_DATASET_NAME
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
const { CacherMiddleware } = require('@semapps/webacl');
|
||||
const CONFIG = require('./config/config');
|
||||
|
||||
Error.stackTraceLimit = Infinity;
|
||||
|
||||
const cacherConfig = CONFIG.REDIS_CACHE_URL
|
||||
? {
|
||||
type: 'Redis',
|
||||
options: {
|
||||
prefix: 'action',
|
||||
ttl: 2592000,
|
||||
redis: CONFIG.REDIS_CACHE_URL
|
||||
}
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const SkipOrphanBlankNodesCleanupMiddleware = () => ({
|
||||
name: 'SkipOrphanBlankNodesCleanupMiddleware',
|
||||
localAction: (next, action) => {
|
||||
if (action.name === 'triplestore.deleteOrphanBlankNodes') {
|
||||
return async () => {};
|
||||
}
|
||||
return next;
|
||||
}
|
||||
});
|
||||
|
||||
// ActivityPods app services mark internal calls with isSystemCall, but the SDK
|
||||
// triplestore layer needs an explicit webId='system' to bypass pod-side WAC.
|
||||
const SystemCallWebIdMiddleware = () => ({
|
||||
name: 'SystemCallWebIdMiddleware',
|
||||
localAction: (next, action) => {
|
||||
return async ctx => {
|
||||
if (ctx.meta?.isSystemCall && !ctx.params?.webId) {
|
||||
ctx.params.webId = 'system';
|
||||
}
|
||||
return next(ctx);
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
middlewares: [
|
||||
SkipOrphanBlankNodesCleanupMiddleware(),
|
||||
SystemCallWebIdMiddleware(),
|
||||
CacherMiddleware(cacherConfig)
|
||||
],
|
||||
logger: {
|
||||
type: 'Console',
|
||||
options: {
|
||||
formatter: 'short',
|
||||
level: 'info'
|
||||
}
|
||||
},
|
||||
retryPolicy: {
|
||||
enabled: true,
|
||||
retries: 0,
|
||||
delay: 100,
|
||||
maxDelay: 30000,
|
||||
factor: 2,
|
||||
check: err => err && !!err.retryable
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "festipod-backend",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "tsx node_modules/moleculer/bin/moleculer-runner.js --hot --repl services/*.service.js services/**/*.service.js",
|
||||
"start": "tsx node_modules/moleculer/bin/moleculer-runner.js services/*.service.js services/**/*.service.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@activitypods/app": "2.3.2",
|
||||
"@rdfjs/data-model": "2.1.1",
|
||||
"@semapps/activitypub": "1.2.7",
|
||||
"@semapps/auth": "1.2.7",
|
||||
"@semapps/core": "1.2.7",
|
||||
"@semapps/crypto": "1.2.7",
|
||||
"@semapps/importer": "1.2.7",
|
||||
"@semapps/ldp": "1.2.7",
|
||||
"@semapps/mime-types": "1.2.7",
|
||||
"@semapps/nodeinfo": "1.2.7",
|
||||
"@semapps/ontologies": "1.2.7",
|
||||
"@semapps/solid": "1.2.7",
|
||||
"@semapps/sparql-endpoint": "1.2.7",
|
||||
"@semapps/triplestore": "1.2.7",
|
||||
"@semapps/webacl": "1.2.7",
|
||||
"dotenv-flow": "^3.2.0",
|
||||
"ioredis": "^4.17.3",
|
||||
"moleculer": "^0.14.19",
|
||||
"moleculer-bull": "0.2.8",
|
||||
"moleculer-cron": "0.0.4",
|
||||
"moleculer-repl": "^0.6.6",
|
||||
"sharp": "0.31.3",
|
||||
"tsx": "^4.20.3",
|
||||
"url-join": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12"
|
||||
},
|
||||
"packageManager": "pnpm@10.26.0+sha512.3b3f6c725ebe712506c0ab1ad4133cf86b1f4b687effce62a9b38b4d72e3954242e643190fc51fa1642949c735f403debd44f5cb0edd657abe63a8b6a7e1e402"
|
||||
}
|
||||
Generated
+7629
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,151 @@
|
||||
const urlJoin = require('url-join');
|
||||
const { AppService } = require('@activitypods/app');
|
||||
const CONFIG = require('../config/config');
|
||||
|
||||
// `GET /app` has two personalities, discriminated by the request's `JsonLdContext` header.
|
||||
//
|
||||
// 1. Solid-OIDC Client ID Document. The pod provider's OIDC adapter (RedisAdapter.find)
|
||||
// resolves an unknown `client_id` that starts with `http` by fetching it, sending
|
||||
// `Accept: application/ld+json` and `JsonLdContext: https://www.w3.org/ns/solid/oidc-context.jsonld`.
|
||||
// The pod provider runs the login with `client_id = <HOME_URL>/app`, so this same URL must,
|
||||
// for that fetch, return a document whose `@context` literally includes the Solid-OIDC
|
||||
// context, whose `client_id` equals the fetched URI exactly, and which carries `redirect_uris`.
|
||||
// Anything else fails `invalid_client_metadata / client_id is mandatory property`.
|
||||
//
|
||||
// 2. SAI app description (the actor). The pod provider's auth-agent fetches this URL
|
||||
// WITHOUT the `JsonLdContext` header (`ldp.remote.getNetwork`) to install/describe the app,
|
||||
// and expects the app actor document served today (`type: [Application, interop:Application]`).
|
||||
//
|
||||
// The `JsonLdContext` header is the only discriminator: same path, two representations.
|
||||
module.exports = {
|
||||
mixins: [AppService],
|
||||
settings: {
|
||||
baseUrl: CONFIG.HOME_URL,
|
||||
app: {
|
||||
name: CONFIG.APP_NAME,
|
||||
description: CONFIG.APP_DESCRIPTION,
|
||||
thumbnail: urlJoin(CONFIG.FRONT_URL, 'logo.svg'),
|
||||
frontUrl: CONFIG.FRONT_URL,
|
||||
supportedLocales: CONFIG.APP_LANG
|
||||
},
|
||||
oidc: {
|
||||
clientUri: CONFIG.FRONT_URL,
|
||||
redirectUris: urlJoin(CONFIG.FRONT_URL, 'login'),
|
||||
postLogoutRedirectUris: urlJoin(CONFIG.FRONT_URL, 'login?logout=true'),
|
||||
tosUri: null
|
||||
},
|
||||
accessNeeds: {
|
||||
required: [
|
||||
{
|
||||
shapeTreeUri: urlJoin(CONFIG.SHAPE_REPOSITORY_URL, 'shapetrees/as/Event'),
|
||||
accessMode: ['acl:Read', 'acl:Write', 'acl:Control'],
|
||||
preferredScope: 'interop:AllFromRegistry'
|
||||
},
|
||||
{
|
||||
shapeTreeUri: urlJoin(CONFIG.SHAPE_REPOSITORY_URL, 'shapetrees/as/Profile'),
|
||||
accessMode: ['acl:Read', 'acl:Write'],
|
||||
preferredScope: 'interop:AllFromRegistry'
|
||||
},
|
||||
{
|
||||
shapeTreeUri: urlJoin(CONFIG.SHAPE_REPOSITORY_URL, 'shapetrees/vcard/Group'),
|
||||
accessMode: 'acl:Read',
|
||||
preferredScope: 'interop:AllFromRegistry'
|
||||
},
|
||||
'apods:ReadInbox',
|
||||
'apods:ReadOutbox',
|
||||
'apods:PostOutbox',
|
||||
'apods:QuerySparqlEndpoint',
|
||||
'apods:CreateWacGroup',
|
||||
'apods:CreateCollection',
|
||||
'apods:UpdateWebId'
|
||||
],
|
||||
optional: []
|
||||
},
|
||||
queueServiceUrl: CONFIG.QUEUE_SERVICE_URL
|
||||
},
|
||||
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.
|
||||
get: {
|
||||
async handler(ctx) {
|
||||
// moleculer-web exposes the raw request only when the route alias sets
|
||||
// passReqResToParams (see services/core/core.service.js). Internal calls
|
||||
// (ctx.call('app.get')) have no `$req`, so they always get the actor.
|
||||
const req = ctx.params && ctx.params.$req;
|
||||
const headers = (req && req.headers) || {};
|
||||
const jsonLdContextKey = Object.keys(headers).find(key => key.toLowerCase() === 'jsonldcontext');
|
||||
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.
|
||||
let actor = this.appActor;
|
||||
if (!actor) {
|
||||
try {
|
||||
actor = await ctx.call('ldp.resource.get', {
|
||||
resourceUri: urlJoin(CONFIG.HOME_URL, 'as/actor/app'),
|
||||
accept: 'application/ld+json',
|
||||
webId: 'system'
|
||||
});
|
||||
} catch (e) {
|
||||
actor = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (isOidcClientIdDocument) {
|
||||
// The client_id must equal the fetched URI exactly. Both `/app` and `/as/actor/app`
|
||||
// serve this document (the configured client_id is the actor uri; `/app` remains
|
||||
// a valid alias), so echo the request path rather than hardcoding one.
|
||||
// `req.url` is rewritten to the alias remainder by moleculer-web; the raw path
|
||||
// survives in `originalUrl`.
|
||||
const requestPath = String((req && (req.originalUrl || req.url)) || '/as/actor/app').split('?')[0];
|
||||
return {
|
||||
'@context': ['https://www.w3.org/ns/solid/oidc-context.jsonld'],
|
||||
client_id: urlJoin(CONFIG.HOME_URL, requestPath),
|
||||
redirect_uris: [urlJoin(CONFIG.FRONT_URL, 'login')],
|
||||
client_name: CONFIG.APP_NAME,
|
||||
client_uri: CONFIG.FRONT_URL
|
||||
};
|
||||
}
|
||||
|
||||
return actor;
|
||||
}
|
||||
}
|
||||
},
|
||||
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. Idempotent: only patches when the link is missing.
|
||||
const actorUri = urlJoin(CONFIG.HOME_URL, 'as/actor/app');
|
||||
const actor = await this.broker.call('ldp.resource.get', {
|
||||
resourceUri: actorUri,
|
||||
accept: 'application/ld+json',
|
||||
webId: 'system'
|
||||
});
|
||||
const link = actor && actor['interop:hasAccessNeedGroup'];
|
||||
const hasLink = Array.isArray(link) ? link.length > 0 : Boolean(link);
|
||||
if (!hasLink) {
|
||||
const groups = await this.broker.call('access-needs-groups.list', {
|
||||
filters: {
|
||||
'http://www.w3.org/ns/solid/interop#accessNecessity': 'http://www.w3.org/ns/solid/interop#AccessRequired'
|
||||
},
|
||||
accept: 'application/ld+json'
|
||||
});
|
||||
const group = groups && groups['ldp:contains'] && groups['ldp:contains'][0];
|
||||
if (group) {
|
||||
await this.broker.call('actors.attachAccessNeedGroup', {
|
||||
accessNeedGroupUri: group.id || group['@id']
|
||||
});
|
||||
this.logger.info(`Healed the actor's interop:hasAccessNeedGroup link to ${group.id || group['@id']}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
const urlJoin = require('url-join');
|
||||
const { PodCollectionsHandlerMixin } = require('@activitypods/app');
|
||||
const CONFIG = require('../config/config');
|
||||
|
||||
// Visibility decision: the attendees collection is readable by ANY visitor, including
|
||||
// unauthenticated ones (acl:agentClass foaf:Agent). A meeting point's participant count and
|
||||
// its list of participants must be visible to non-contacts — the app shows outsiders the
|
||||
// anonymous participants plus the total counter, and that is a product feature, not a leak.
|
||||
// Member URIs are not secret; the anonymity guarantee lives in each profile's own WAC (a
|
||||
// participant whose profile is not shared simply renders as anonymous), not here. The
|
||||
// reference welcometomyplace grants this to its announces group, but Festipod has no
|
||||
// announces concept, so the grant is public.
|
||||
module.exports = {
|
||||
name: 'attendees',
|
||||
mixins: [PodCollectionsHandlerMixin],
|
||||
settings: {
|
||||
shapeTreeUri: urlJoin(CONFIG.SHAPE_REPOSITORY_URL, 'shapetrees/as/Event'),
|
||||
attachPredicate: 'http://activitypods.org/ns/core#attendees',
|
||||
collectionOptions: {
|
||||
ordered: false,
|
||||
summary: 'Meeting point attendees'
|
||||
},
|
||||
createWacGroup: true
|
||||
},
|
||||
actions: {
|
||||
async givePublicReadPermission(ctx) {
|
||||
const { collectionUri, actorUri } = ctx.params;
|
||||
|
||||
await ctx.call('pod-permissions.add', {
|
||||
uri: collectionUri,
|
||||
agentPredicate: 'acl:agentClass',
|
||||
agentUri: 'http://xmlns.com/foaf/0.1/Agent',
|
||||
mode: 'acl:Read',
|
||||
actorUri
|
||||
});
|
||||
}
|
||||
},
|
||||
hooks: {
|
||||
after: {
|
||||
async createAndAttach(ctx, res) {
|
||||
const { actorUri } = ctx.params;
|
||||
const collectionUri = res;
|
||||
|
||||
await this.actions.givePublicReadPermission({ collectionUri, actorUri }, { parentCtx: ctx });
|
||||
|
||||
return res;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
const urlJoin = require('url-join');
|
||||
const { PodResourcesHandlerMixin } = require('@activitypods/app');
|
||||
const CONFIG = require('../config/config');
|
||||
|
||||
module.exports = {
|
||||
name: 'catalog',
|
||||
mixins: [PodResourcesHandlerMixin],
|
||||
settings: {
|
||||
shapeTreeUri: urlJoin(CONFIG.SHAPE_REPOSITORY_URL, 'shapetrees/as/Event')
|
||||
},
|
||||
actions: {
|
||||
discover: {
|
||||
async handler() {
|
||||
return {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
type: 'Collection',
|
||||
summary: 'Public Festipod events and meeting points',
|
||||
totalItems: this.index ? Object.keys(this.index).length : 0,
|
||||
items: this.index ? Object.values(this.index) : []
|
||||
};
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async onCreate(ctx, resource) {
|
||||
if (!this.index) this.index = {};
|
||||
this.index[resource.id || resource['@id']] = resource;
|
||||
this.logger.info(`Catalog indexed new event ${resource.id || resource['@id']}`);
|
||||
},
|
||||
async onUpdate(ctx, resource) {
|
||||
if (!this.index) this.index = {};
|
||||
this.index[resource.id || resource['@id']] = resource;
|
||||
this.logger.info(`Catalog updated event ${resource.id || resource['@id']}`);
|
||||
},
|
||||
async onDelete(ctx, resourceUri) {
|
||||
if (!this.index) return;
|
||||
delete this.index[resourceUri];
|
||||
this.logger.info(`Catalog removed event ${resourceUri}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
const urlJoin = require('url-join');
|
||||
const CONFIG = require('../config/config');
|
||||
|
||||
// Serves the app actor at its canonical uri (`/as/actor/app`) with a Solid-OIDC dual
|
||||
// personality, discriminated by the request's `JsonLdContext` header — same contract as
|
||||
// the `/app` route (see app.service.js for the full story), but implemented as a
|
||||
// standalone service with no dependency on the `app` service.
|
||||
//
|
||||
// WHY A SEPARATE SERVICE: the Solid-OIDC client_id IS the actor uri (the pod provider's
|
||||
// auth-agent only returns the `Link: rel="interop:registeredAgent"` header when the
|
||||
// requesting app's identity — the token's azp, i.e. this client_id — matches the
|
||||
// registration's `registeredAgent`, which the pod stores under the actor uri). The
|
||||
// adapter therefore fetches THIS path as the client metadata document. Routing the path
|
||||
// to `app.get` deadlocked boot: `@activitypods/app`'s `awaitCreateComplete` polls the
|
||||
// actor over HTTP while the `app` service is still starting, and an unregistered action
|
||||
// 503s the poll it waits on. This service has no `started()` work and no dependencies,
|
||||
// so it is routable long before the `app` service exists.
|
||||
module.exports = {
|
||||
name: 'client-id-doc',
|
||||
actions: {
|
||||
get: {
|
||||
async handler(ctx) {
|
||||
const req = ctx.params && ctx.params.$req;
|
||||
const headers = (req && req.headers) || {};
|
||||
const jsonLdContextKey = Object.keys(headers).find(key => key.toLowerCase() === 'jsonldcontext');
|
||||
const isOidcClientIdDocument =
|
||||
jsonLdContextKey !== undefined && String(headers[jsonLdContextKey]).includes('solid/oidc-context.jsonld');
|
||||
|
||||
if (isOidcClientIdDocument) {
|
||||
// The client_id must equal the fetched URI exactly. `req.url` is rewritten to
|
||||
// the alias remainder by moleculer-web; the raw path survives in `originalUrl`.
|
||||
const requestPath = String((req && (req.originalUrl || req.url)) || '/as/actor/app').split('?')[0];
|
||||
return {
|
||||
'@context': ['https://www.w3.org/ns/solid/oidc-context.jsonld'],
|
||||
client_id: urlJoin(CONFIG.HOME_URL, requestPath),
|
||||
redirect_uris: [urlJoin(CONFIG.FRONT_URL, 'login')],
|
||||
client_name: CONFIG.APP_NAME,
|
||||
client_uri: CONFIG.FRONT_URL
|
||||
};
|
||||
}
|
||||
|
||||
return await ctx.call('ldp.resource.get', {
|
||||
resourceUri: urlJoin(CONFIG.HOME_URL, 'as/actor/app'),
|
||||
accept: 'application/ld+json',
|
||||
webId: 'system'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
const urlJoin = require('url-join');
|
||||
const { PodCollectionsHandlerMixin } = require('@activitypods/app');
|
||||
const CONFIG = require('../config/config');
|
||||
|
||||
module.exports = {
|
||||
name: 'contacts',
|
||||
mixins: [PodCollectionsHandlerMixin],
|
||||
settings: {
|
||||
shapeTreeUri: urlJoin(CONFIG.SHAPE_REPOSITORY_URL, 'shapetrees/as/Profile'),
|
||||
attachPredicate: 'http://activitypods.org/ns/core#contacts',
|
||||
collectionOptions: {
|
||||
ordered: false,
|
||||
summary: 'Festipod contacts'
|
||||
},
|
||||
createWacGroup: true
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
const { AuthAccountService } = require('@semapps/auth');
|
||||
const { TripleStoreAdapter } = require('@semapps/triplestore');
|
||||
const CONFIG = require('../../config/config');
|
||||
|
||||
module.exports = {
|
||||
mixins: [AuthAccountService],
|
||||
adapter: new TripleStoreAdapter({ type: 'AuthAccount', dataset: CONFIG.AUTH_ACCOUNTS_DATASET_NAME })
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
const path = require('path');
|
||||
const { CoreService } = require('@semapps/core');
|
||||
const { apods, notify, interop, oidc, skos } = require('@semapps/ontologies');
|
||||
const CONFIG = require('../../config/config');
|
||||
|
||||
module.exports = {
|
||||
mixins: [CoreService],
|
||||
settings: {
|
||||
baseUrl: CONFIG.HOME_URL,
|
||||
baseDir: path.resolve(__dirname, '../..'),
|
||||
triplestore: {
|
||||
url: CONFIG.SPARQL_ENDPOINT,
|
||||
user: CONFIG.JENA_USER,
|
||||
password: CONFIG.JENA_PASSWORD,
|
||||
mainDataset: CONFIG.MAIN_DATASET
|
||||
},
|
||||
ontologies: [apods, notify, interop, oidc, skos],
|
||||
activitypub: {
|
||||
queueServiceUrl: CONFIG.QUEUE_SERVICE_URL
|
||||
},
|
||||
api: {
|
||||
port: CONFIG.PORT,
|
||||
routes: [
|
||||
{
|
||||
path: '/app',
|
||||
aliases: {
|
||||
'GET /': {
|
||||
action: 'app.get',
|
||||
// `app.get` must see the JsonLdContext request header to answer the Solid-OIDC
|
||||
// Client ID Document fetch; moleculer-web only exposes the raw request via
|
||||
// ctx.params.$req when the alias opts in.
|
||||
passReqResToParams: true
|
||||
}
|
||||
},
|
||||
mappingPolicy: 'restrict',
|
||||
authentication: false,
|
||||
authorization: false,
|
||||
bodyParsers: {
|
||||
json: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/as/actor/app',
|
||||
aliases: {
|
||||
'GET /': {
|
||||
// Served by a standalone service (not `app.get`): the Solid-OIDC client_id
|
||||
// IS the actor uri, and routing this to `app.get` deadlocked boot (the app
|
||||
// service's own startup polls this path over HTTP). See client-id-doc.service.js.
|
||||
action: 'client-id-doc.get',
|
||||
passReqResToParams: true
|
||||
}
|
||||
},
|
||||
mappingPolicy: 'restrict',
|
||||
authentication: false,
|
||||
authorization: false,
|
||||
bodyParsers: {
|
||||
json: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/catalog',
|
||||
aliases: {
|
||||
'GET /': 'catalog.discover'
|
||||
},
|
||||
mappingPolicy: 'restrict',
|
||||
authentication: false,
|
||||
authorization: false,
|
||||
bodyParsers: {
|
||||
json: true
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
webacl: false,
|
||||
void: false
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
const urlJoin = require('url-join');
|
||||
const { NodeinfoService } = require('@semapps/nodeinfo');
|
||||
const CONFIG = require('../../config/config');
|
||||
|
||||
module.exports = {
|
||||
mixins: [NodeinfoService],
|
||||
settings: {
|
||||
baseUrl: CONFIG.HOME_URL,
|
||||
software: {
|
||||
name: 'festipod'
|
||||
},
|
||||
protocols: ['activitypub'],
|
||||
metadata: {
|
||||
frontend_url: CONFIG.FRONT_URL,
|
||||
login_url: CONFIG.FRONT_URL && urlJoin(CONFIG.FRONT_URL, 'login'),
|
||||
logout_url: CONFIG.FRONT_URL && urlJoin(CONFIG.FRONT_URL, 'login?logout=true')
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
async getUsersCount(ctx) {
|
||||
const containerUri = await ctx.call('app-registrations.getContainerUri');
|
||||
const registrationsUris = await ctx.call('ldp.container.getUris', { containerUri });
|
||||
return {
|
||||
total: registrationsUris.length,
|
||||
activeHalfYear: registrationsUris.length,
|
||||
activeMonth: registrationsUris.length
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
const { TripleStoreAdapter } = require('@semapps/triplestore');
|
||||
const { NotificationsListenerService } = require('@semapps/solid');
|
||||
const CONFIG = require('../../config/config');
|
||||
|
||||
module.exports = {
|
||||
mixins: [NotificationsListenerService],
|
||||
adapter: new TripleStoreAdapter({ type: 'WebhookChannelListener', dataset: CONFIG.AUTH_ACCOUNTS_DATASET_NAME }),
|
||||
settings: {
|
||||
baseUrl: CONFIG.HOME_URL
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
const { ProxyService } = require('@semapps/crypto');
|
||||
|
||||
module.exports = ProxyService;
|
||||
@@ -0,0 +1,120 @@
|
||||
const { ACTIVITY_TYPES, OBJECT_TYPES } = require('@semapps/activitypub');
|
||||
const { PodActivitiesHandlerMixin } = require('@activitypods/app');
|
||||
const { arrayOf } = require('@semapps/ldp');
|
||||
|
||||
module.exports = {
|
||||
name: 'registration',
|
||||
mixins: [PodActivitiesHandlerMixin],
|
||||
async started() {
|
||||
this.logger.info('Registration watcher subscribed');
|
||||
},
|
||||
methods: {
|
||||
/**
|
||||
* The Event's `apods:attendees` collection is attached asynchronously by
|
||||
* PodCollectionsHandlerMixin on create, so a Join/Leave processed in that window sees no
|
||||
* collection. Returning immediately would drop the activity forever (the client overlay
|
||||
* shows joined, nothing reconciles). Re-fetch the Event a bounded number of times until the
|
||||
* collection appears; only then give up.
|
||||
*/
|
||||
async fetchAttendeesCollectionUri(ctx, event, actorUri) {
|
||||
if (event['apods:attendees'] || !event.id) return event['apods:attendees'];
|
||||
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
try {
|
||||
const { ok, body } = await ctx.call('pod-resources.get', { resourceUri: event.id, actorUri });
|
||||
if (ok && body && body['apods:attendees']) return body['apods:attendees'];
|
||||
} catch (e) {
|
||||
this.logger.warn(`Could not re-fetch ${event.id} while waiting for its attendees collection: ${e.message}`);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
activities: {
|
||||
join: {
|
||||
match: {
|
||||
type: ACTIVITY_TYPES.JOIN,
|
||||
object: {
|
||||
type: OBJECT_TYPES.EVENT
|
||||
}
|
||||
},
|
||||
async onEmit(ctx, activity, actorUri) {
|
||||
const event = activity.object;
|
||||
const organizerUri = event['dc:creator'];
|
||||
if (!organizerUri) return;
|
||||
|
||||
const { body: actor } = await ctx.call('pod-resources.get', { resourceUri: actorUri, actorUri });
|
||||
if (!actor || !actor['apods:contacts']) return;
|
||||
|
||||
const contactsGroupUri = await ctx.call('pod-wac-groups.getUriFromCollectionUri', {
|
||||
collectionUri: actor['apods:contacts']
|
||||
});
|
||||
|
||||
await ctx.call('pod-wac-groups.addMember', {
|
||||
groupUri: contactsGroupUri,
|
||||
memberUri: organizerUri,
|
||||
actorUri
|
||||
});
|
||||
},
|
||||
async onReceive(ctx, activity, actorUri) {
|
||||
const event = activity.object;
|
||||
const attendeesCollectionUri = await this.fetchAttendeesCollectionUri(ctx, event, actorUri);
|
||||
|
||||
if (!attendeesCollectionUri) {
|
||||
this.logger.warn(`No attendees collection on ${event.id}, skipping Join`);
|
||||
return;
|
||||
}
|
||||
|
||||
const attendees = await ctx.call('pod-collections.getItems', {
|
||||
collectionUri: attendeesCollectionUri,
|
||||
actorUri
|
||||
});
|
||||
|
||||
if (arrayOf(attendees).includes(activity.actor)) {
|
||||
this.logger.info(`${activity.actor} is already attending ${event.id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.call('attendees.add', {
|
||||
collectionUri: attendeesCollectionUri,
|
||||
itemUri: activity.actor,
|
||||
actorUri
|
||||
});
|
||||
}
|
||||
},
|
||||
leave: {
|
||||
match: {
|
||||
type: ACTIVITY_TYPES.LEAVE,
|
||||
object: {
|
||||
type: OBJECT_TYPES.EVENT
|
||||
}
|
||||
},
|
||||
async onReceive(ctx, activity, actorUri) {
|
||||
const event = activity.object;
|
||||
const attendeesCollectionUri = await this.fetchAttendeesCollectionUri(ctx, event, actorUri);
|
||||
|
||||
if (!attendeesCollectionUri) {
|
||||
this.logger.warn(`No attendees collection on ${event.id}, skipping Leave`);
|
||||
return;
|
||||
}
|
||||
|
||||
const attendees = await ctx.call('pod-collections.getItems', {
|
||||
collectionUri: attendeesCollectionUri,
|
||||
actorUri
|
||||
});
|
||||
|
||||
if (!arrayOf(attendees).includes(activity.actor)) {
|
||||
this.logger.info(`${activity.actor} is not attending ${event.id}, skipping Leave`);
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.call('attendees.remove', {
|
||||
collectionUri: attendeesCollectionUri,
|
||||
itemUri: activity.actor,
|
||||
actorUri
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
+2
-2
@@ -6,7 +6,7 @@
|
||||
"src/modules/*/steps/**/*.ts"
|
||||
],
|
||||
"paths": ["src/modules/*/features/**/*.feature"],
|
||||
"tags": "not @wip and not @humain",
|
||||
"tags": "not @wip and not @humain and not @suspendu and not @e2e-activitypods",
|
||||
"format": [
|
||||
"progress-bar",
|
||||
"json:reports/cucumber-report.json",
|
||||
@@ -16,6 +16,6 @@
|
||||
"formatOptions": {
|
||||
"snippetInterface": "async-await"
|
||||
},
|
||||
"strict": false
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"default": {
|
||||
"import": [
|
||||
"src/shared/support/**/*.ts",
|
||||
"src/shared/steps/**/*.ts",
|
||||
"src/modules/*/steps/**/*.ts"
|
||||
],
|
||||
"paths": ["src/modules/event/features/e2e-activitypods.feature"],
|
||||
"tags": "@e2e-activitypods and not @suspendu",
|
||||
"format": ["progress-bar"],
|
||||
"language": "fr",
|
||||
"formatOptions": {
|
||||
"snippetInterface": "async-await"
|
||||
},
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
services:
|
||||
frontend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "4000:3000"
|
||||
environment:
|
||||
- PORT=3000
|
||||
- NODE_ENV=production
|
||||
- VITE_BACKEND_URL=${VITE_BACKEND_URL:-http://localhost:3003}
|
||||
- VITE_BACKEND_CLIENT_ID=${VITE_BACKEND_CLIENT_ID:-}
|
||||
- FESTIPOD_SHARED_WALLET_PASSWORD=${FESTIPOD_SHARED_WALLET_PASSWORD:-}
|
||||
- FESTIPOD_SHARED_WALLET_FILE=${FESTIPOD_SHARED_WALLET_FILE:-}
|
||||
- FESTIPOD_SHARED_WALLET_FILE_BASE64=${FESTIPOD_SHARED_WALLET_FILE_BASE64:-}
|
||||
- FESTIPOD_AUTO_SEED=${FESTIPOD_AUTO_SEED:-}
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "3003:3003"
|
||||
env_file:
|
||||
- path: ./backend/.env
|
||||
required: false
|
||||
environment:
|
||||
- SEMAPPS_HOME_URL=${SEMAPPS_HOME_URL:-http://localhost:3003}
|
||||
- SEMAPPS_FRONT_URL=${SEMAPPS_FRONT_URL:-http://localhost:4000}
|
||||
+3
-1
@@ -9,8 +9,8 @@
|
||||
"build": "bun run build.ts",
|
||||
"test:cucumber": "bun run cucumber:run && bun run cucumber:report && bun run features:parse && bun run steps:extract",
|
||||
"cucumber:run": "node --import tsx/esm node_modules/@cucumber/cucumber/bin/cucumber.js --config cucumber.json",
|
||||
"test:data": "node --import tsx/esm node_modules/@cucumber/cucumber/bin/cucumber.js --config cucumber.json --tags @data",
|
||||
"test:auth-setup": "bun scripts/setup-test-auth.ts",
|
||||
"test:e2e-flow": "bun scripts/e2e-login-flow.ts",
|
||||
"cucumber:report": "bun scripts/parse-test-results.ts",
|
||||
"features:parse": "bun scripts/parse-features.ts",
|
||||
"steps:extract": "bun scripts/extract-step-definitions.ts",
|
||||
@@ -23,6 +23,7 @@
|
||||
"build-storybook": "storybook build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@activitypods/refine-providers": "^0.2.0",
|
||||
"@ng-eventually/polyfill": "git+https://gitea.reconnexion.apps.gueraud.net/Reconnexion/ng-eventually.git#a8d53010c227462cc9317e9be499c2100ca8d533&path:/packages/polyfill",
|
||||
"@ng-helpers/indexing": "git+https://gitea.reconnexion.apps.gueraud.net/Sylvain/ng-helpers.git#v1.0.1",
|
||||
"@ng-org/alien-deepsignals": "0.1.2-alpha.11",
|
||||
@@ -33,6 +34,7 @@
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@refinedev/core": "^5.0.12",
|
||||
"bun-plugin-tailwind": "^0.1.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
Generated
+299
@@ -8,6 +8,9 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@activitypods/refine-providers':
|
||||
specifier: ^0.2.0
|
||||
version: 0.2.0(@refinedev/core@5.0.12(@tanstack/react-query@5.104.1(react@19.2.7))(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)(web-streams-polyfill@3.3.3)
|
||||
'@ng-eventually/polyfill':
|
||||
specifier: git+https://gitea.reconnexion.apps.gueraud.net/Reconnexion/ng-eventually.git#a8d53010c227462cc9317e9be499c2100ca8d533&path:/packages/polyfill
|
||||
version: git+https://gitea.reconnexion.apps.gueraud.net/Reconnexion/ng-eventually.git#a8d53010c227462cc9317e9be499c2100ca8d533&path:/packages/polyfill(@ng-org/alien-deepsignals@0.1.2-alpha.11(react@19.2.7))(@ng-org/orm@0.1.2-alpha.18(react@19.2.7))(@ng-org/shex-orm@0.1.2-alpha.8(typescript@6.0.3))(@ng-org/web@0.1.2-alpha.13)
|
||||
@@ -38,6 +41,9 @@ importers:
|
||||
'@radix-ui/react-tooltip':
|
||||
specifier: ^1.2.8
|
||||
version: 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@refinedev/core':
|
||||
specifier: ^5.0.12
|
||||
version: 5.0.12(@tanstack/react-query@5.104.1(react@19.2.7))(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
bun-plugin-tailwind:
|
||||
specifier: ^0.1.2
|
||||
version: 0.1.2(bun@1.3.14)
|
||||
@@ -120,6 +126,24 @@ importers:
|
||||
|
||||
packages:
|
||||
|
||||
'@activitypods/refine-providers@0.2.0':
|
||||
resolution: {integrity: sha512-iuhN19ogAUM9rxv+hQAKz8MSNAhYJe/rKIfm+Ykp1kiOBJxZ9B8tfS25AQx73QW+1N1z3mxW6sp+pdM1GxxR4A==}
|
||||
peerDependencies:
|
||||
'@ant-design/icons': ^5.5.1
|
||||
'@refinedev/core': ^5.0.12
|
||||
antd: ^5.23.0
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
react-router: ^7.0.2
|
||||
peerDependenciesMeta:
|
||||
'@ant-design/icons':
|
||||
optional: true
|
||||
antd:
|
||||
optional: true
|
||||
react:
|
||||
optional: true
|
||||
react-router:
|
||||
optional: true
|
||||
|
||||
'@adobe/css-tools@4.5.0':
|
||||
resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==}
|
||||
|
||||
@@ -265,6 +289,10 @@ packages:
|
||||
'@cucumber/tag-expressions@9.1.0':
|
||||
resolution: {integrity: sha512-bvHjcRFZ+J1TqIa9eFNO1wGHqwx4V9ZKV3hYgkuK/VahHx73uiP4rKV3JVrvWSMrwrFvJG6C8aEwnCWSvbyFdQ==}
|
||||
|
||||
'@digitalbazaar/http-client@3.4.1':
|
||||
resolution: {integrity: sha512-Ahk1N+s7urkgj7WvvUND5f8GiWEPfUw0D41hdElaqLgu8wZScI8gdI0q+qWw5N1d35x7GCRH2uk9mi+Uzo9M3g==}
|
||||
engines: {node: '>=14.0'}
|
||||
|
||||
'@emnapi/core@1.11.2':
|
||||
resolution: {integrity: sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==}
|
||||
|
||||
@@ -439,6 +467,10 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@fastify/busboy@2.1.1':
|
||||
resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
'@floating-ui/core@1.8.0':
|
||||
resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==}
|
||||
|
||||
@@ -1134,6 +1166,34 @@ packages:
|
||||
'@rdfjs/types@1.1.2':
|
||||
resolution: {integrity: sha512-wqpOJK1QCbmsGNtyzYnojPU8gRDPid2JO0Q0kMtb4j65xhCK880cnKAfEOwC+dX85VJcCByQx5zOwyyfCjDJsg==}
|
||||
|
||||
'@refinedev/core@5.0.12':
|
||||
resolution: {integrity: sha512-9y5Bi9Lb7XyJmM55b8rCeBTDRCBU41p47OymJldasLfrtpUm2EwI+27DjjNpHTOugymiZsIbLlPtHCPQIXBHcg==}
|
||||
engines: {node: '>=20'}
|
||||
peerDependencies:
|
||||
'@tanstack/react-query': ^5.81.5
|
||||
'@types/react': ^18.0.0 || ^19.0.0
|
||||
'@types/react-dom': ^18.0.0 || ^19.0.0
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
react-dom: ^18.0.0 || ^19.0.0
|
||||
|
||||
'@refinedev/devtools-internal@2.0.2':
|
||||
resolution: {integrity: sha512-1YYizOW1lyy9ep8eQ7TcUPBooKXIlvzTLjLdDArsQwx7P33cn2uXdqM7So5VhlNFXhjOjAKFgrH5c1jleRF8Jg==}
|
||||
engines: {node: '>=20'}
|
||||
peerDependencies:
|
||||
'@types/react': ^18.0.0 || ^19.0.0
|
||||
'@types/react-dom': ^18.0.0 || ^19.0.0
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
react-dom: ^18.0.0 || ^19.0.0
|
||||
|
||||
'@refinedev/devtools-shared@2.0.2':
|
||||
resolution: {integrity: sha512-3cTjR1mEWn0tHFZBfPD5aVpBGLUhpAkfjqYCwKrijIicr1Utp/j0BqiPRnNqTf+W71HTng3znBpUhnR83u+tuA==}
|
||||
engines: {node: '>=20'}
|
||||
peerDependencies:
|
||||
'@types/react': ^18.0.0 || ^19.0.0
|
||||
'@types/react-dom': ^18.0.0 || ^19.0.0
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
react-dom: ^18.0.0 || ^19.0.0
|
||||
|
||||
'@shexjs/parser@1.0.0-alpha.28':
|
||||
resolution: {integrity: sha512-eeVeHq/2JG9X+3h7y+7EmuBSWWl2EMj/EQBLk5CTRx4W4hWDdjWczsY8RWwKjkIzLwUS1+G0aiAI1u5LHCZ2Rw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -1361,6 +1421,14 @@ packages:
|
||||
'@swc/types@0.1.27':
|
||||
resolution: {integrity: sha512-K6h3iUlqeM946U4sXFYeahefR1YBbXJvko+hv8WS8/0BNJ4OHiHRywMnQUJCqkR7Y9+hqQ1TvEpiKqUhz7NEFg==}
|
||||
|
||||
'@tanstack/query-core@5.104.1':
|
||||
resolution: {integrity: sha512-TiRghcrGkUTE+M4JBxvpPSpBhxvIzQairw+cksrHYGr+2uwC77yW1SY9nYxnYRYOppvucf8rEg6b5byoz4rTpw==}
|
||||
|
||||
'@tanstack/react-query@5.104.1':
|
||||
resolution: {integrity: sha512-Zz70EgjahNI7aCz8BZxM3vUGU44ycxEAzMuCUjLeA7SVhhOkg3ZgPjPJPvHjs6Rmky/dtmnu1WzjGHNHmhCoZQ==}
|
||||
peerDependencies:
|
||||
react: ^18 || ^19
|
||||
|
||||
'@teppeis/multimaps@3.0.0':
|
||||
resolution: {integrity: sha512-ID7fosbc50TbT0MK0EG12O+gAP3W3Aa/Pz4DaTtQtEvlc9Odaqi0de+xuZ7Li2GtK4HzEX7IuRWS/JmZLksR3Q==}
|
||||
engines: {node: '>=14'}
|
||||
@@ -1549,6 +1617,10 @@ packages:
|
||||
'@xtuc/long@4.2.2':
|
||||
resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==}
|
||||
|
||||
abort-controller@3.0.0:
|
||||
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
|
||||
engines: {node: '>=6.5'}
|
||||
|
||||
acorn-import-phases@1.0.4:
|
||||
resolution: {integrity: sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
@@ -1725,6 +1797,9 @@ packages:
|
||||
caniuse-lite@1.0.30001805:
|
||||
resolution: {integrity: sha512-52noaS3DubycKSXaU30TwPGIp+POyQSUVa5jBEq3vkRkY0kjyb3LQgvhU6WGyCcyXqVLWO0Cw0Q6BSdD0kUfVA==}
|
||||
|
||||
canonicalize@1.0.8:
|
||||
resolution: {integrity: sha512-0CNTVCLZggSh7bc5VkX5WWPWO+cyZbNd07IHIsSXLia/eAq+r836hgk+8BKoEh7949Mda87VUOitx5OddVj64A==}
|
||||
|
||||
capital-case@1.0.4:
|
||||
resolution: {integrity: sha512-ds37W8CytHgwnhGGTi88pcPyR15qoNkOpYwmMMfnWqqWgESapLqvDx6huFjQ5vqWSn2Z06173XNA7LtMOeUh1A==}
|
||||
|
||||
@@ -1875,6 +1950,10 @@ packages:
|
||||
csstype@3.2.3:
|
||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||
|
||||
data-uri-to-buffer@4.0.1:
|
||||
resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==}
|
||||
engines: {node: '>= 12'}
|
||||
|
||||
debug@4.4.3:
|
||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||
engines: {node: '>=6.0'}
|
||||
@@ -2045,6 +2124,10 @@ packages:
|
||||
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
event-target-shim@5.0.1:
|
||||
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
events@3.3.0:
|
||||
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
|
||||
engines: {node: '>=0.8.x'}
|
||||
@@ -2061,6 +2144,10 @@ packages:
|
||||
fast-uri@3.1.3:
|
||||
resolution: {integrity: sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==}
|
||||
|
||||
fetch-blob@3.2.0:
|
||||
resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==}
|
||||
engines: {node: ^12.20 || >= 14.13}
|
||||
|
||||
figures@3.2.0:
|
||||
resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -2102,6 +2189,10 @@ packages:
|
||||
resolution: {integrity: sha512-Ogz/E85h9tlfJzpI6TuFpGcHZFhLrb9Gw8wq9v40CxSCPnv7ahKr6Xgtkn0KYCDQJ8DNn5VoMO8EXr9V5PadyA==}
|
||||
engines: {node: '>= 0.12'}
|
||||
|
||||
formdata-polyfill@4.0.10:
|
||||
resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==}
|
||||
engines: {node: '>=12.20.0'}
|
||||
|
||||
fs-extra@10.1.0:
|
||||
resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -2357,12 +2448,33 @@ packages:
|
||||
jsonld2graphobject@0.0.5:
|
||||
resolution: {integrity: sha512-5BqfXOq96+OBjjiJNG8gQH66pYt6hW88z2SJxdvFJo4XNoVMvqAcUz+JSm/KEWS5NLRnebApEzFrYP3HUiUmYw==}
|
||||
|
||||
jsonld@8.3.3:
|
||||
resolution: {integrity: sha512-9YcilrF+dLfg9NTEof/mJLMtbdX1RJ8dbWtJgE00cMOIohb1lIyJl710vFiTaiHTl6ZYODJuBd32xFvUhmv3kg==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
jwt-decode@3.1.2:
|
||||
resolution: {integrity: sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A==}
|
||||
|
||||
keyv@4.5.4:
|
||||
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
||||
|
||||
knuth-shuffle-seeded@1.0.6:
|
||||
resolution: {integrity: sha512-9pFH0SplrfyKyojCLxZfMcvkhf5hH0d+UwR9nTVJ/DDQJGuzcXjTwB7TP7sDfehSudlGGaOLblmEWqv04ERVWg==}
|
||||
|
||||
ky-universal@0.11.0:
|
||||
resolution: {integrity: sha512-65KyweaWvk+uKKkCrfAf+xqN2/epw1IJDtlyCPxYffFCMR8u1sp2U65NtWpnozYfZxQ6IUzIlvUcw+hQ82U2Xw==}
|
||||
engines: {node: '>=14.16'}
|
||||
peerDependencies:
|
||||
ky: '>=0.31.4'
|
||||
web-streams-polyfill: '>=3.2.1'
|
||||
peerDependenciesMeta:
|
||||
web-streams-polyfill:
|
||||
optional: true
|
||||
|
||||
ky@0.33.3:
|
||||
resolution: {integrity: sha512-CasD9OCEQSFIam2U8efFK81Yeg8vNMTBUqtMOHlrcWQHqUX3HeCl9Dr31u4toV7emlH8Mymk5+9p0lL6mKb/Xw==}
|
||||
engines: {node: '>=14.16'}
|
||||
|
||||
lines-and-columns@1.2.4:
|
||||
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
|
||||
|
||||
@@ -2377,6 +2489,9 @@ packages:
|
||||
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
lodash-es@4.18.1:
|
||||
resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==}
|
||||
|
||||
lodash.merge@4.6.2:
|
||||
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
||||
|
||||
@@ -2402,6 +2517,10 @@ packages:
|
||||
lru-cache@5.1.1:
|
||||
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
||||
|
||||
lru-cache@6.0.0:
|
||||
resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
lucide-react@0.545.0:
|
||||
resolution: {integrity: sha512-7r1/yUuflQDSt4f1bpn5ZAocyIxcTyVyBBChSVtBKn5M+392cPmI5YJMWOJKk/HUWGm5wg83chlAZtCcGbEZtw==}
|
||||
peerDependencies:
|
||||
@@ -2544,6 +2663,11 @@ packages:
|
||||
node-abort-controller@3.1.1:
|
||||
resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==}
|
||||
|
||||
node-domexception@1.0.0:
|
||||
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
|
||||
engines: {node: '>=10.5.0'}
|
||||
deprecated: Use your platform's native DOMException instead
|
||||
|
||||
node-fetch@2.7.0:
|
||||
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
|
||||
engines: {node: 4.x || >=6.0.0}
|
||||
@@ -2553,6 +2677,10 @@ packages:
|
||||
encoding:
|
||||
optional: true
|
||||
|
||||
node-fetch@3.3.2:
|
||||
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
|
||||
node-releases@2.0.51:
|
||||
resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -2564,6 +2692,9 @@ packages:
|
||||
nth-check@2.1.1:
|
||||
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
|
||||
|
||||
oauth4webapi@2.17.0:
|
||||
resolution: {integrity: sha512-lbC0Z7uzAFNFyzEYRIC+pkSVvDHJTbEW+dYlSBAlCYDe6RxUkJ26bClhk8ocBZip1wfI9uKTe0fm4Ib4RHn6uQ==}
|
||||
|
||||
object-assign@4.1.1:
|
||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -2608,6 +2739,9 @@ packages:
|
||||
resolution: {integrity: sha512-4cy8M95ioIGolCoMmm2cMntGR1lPLEbOMzOKu8bzjuJP6JpzEMQcDHmh7hHLYGgob+nKe1YHFMaG4V59HQa89g==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
papaparse@5.7.0:
|
||||
resolution: {integrity: sha512-qBGxg/7Q3Kl9Wfhrz2Z74UnvnHTXLNG6jmKJFeBvP2+y4lV7So+7SR62+Zd47JvdrCkX+nDcnr0ObPzek/+6RA==}
|
||||
|
||||
param-case@3.0.4:
|
||||
resolution: {integrity: sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==}
|
||||
|
||||
@@ -2677,6 +2811,10 @@ packages:
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
pluralize@8.0.0:
|
||||
resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
postcss-modules-extract-imports@3.1.0:
|
||||
resolution: {integrity: sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==}
|
||||
engines: {node: ^10 || ^12 || >= 14}
|
||||
@@ -2749,6 +2887,10 @@ packages:
|
||||
resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
rdf-canonize@3.4.0:
|
||||
resolution: {integrity: sha512-fUeWjrkOO0t1rg7B2fdyDTvngj+9RlUyL92vOdiB7c0FPguWVsniIMjEtHH+meLBO9rzkUlUzBVXgWrjI8P9LA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
rdf-data-factory@1.1.3:
|
||||
resolution: {integrity: sha512-ny6CI7m2bq4lfQQmDYvcb2l1F9KtGwz9chipX4oWu2aAtVoXjb7k3d8J1EsgAsEbMXnBipB/iuRen5H2fwRWWQ==}
|
||||
|
||||
@@ -2909,6 +3051,9 @@ packages:
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
setimmediate@1.0.5:
|
||||
resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
|
||||
|
||||
side-channel-list@1.0.1:
|
||||
resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -3166,6 +3311,10 @@ packages:
|
||||
undici-types@8.3.0:
|
||||
resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==}
|
||||
|
||||
undici@5.29.0:
|
||||
resolution: {integrity: sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==}
|
||||
engines: {node: '>=14.0'}
|
||||
|
||||
unicorn-magic@0.4.0:
|
||||
resolution: {integrity: sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw==}
|
||||
engines: {node: '>=20'}
|
||||
@@ -3242,10 +3391,17 @@ packages:
|
||||
validate-npm-package-license@3.0.4:
|
||||
resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==}
|
||||
|
||||
warn-once@0.1.1:
|
||||
resolution: {integrity: sha512-VkQZJbO8zVImzYFteBXvBOZEl1qL175WH8VmZcxF2fZAoudNhNDvHi+doCaAEdU2l2vtcIwa2zn0QK5+I1HQ3Q==}
|
||||
|
||||
watchpack@2.5.2:
|
||||
resolution: {integrity: sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
|
||||
web-streams-polyfill@3.3.3:
|
||||
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
webidl-conversions@3.0.1:
|
||||
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
|
||||
|
||||
@@ -3315,6 +3471,9 @@ packages:
|
||||
yallist@3.1.1:
|
||||
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
||||
|
||||
yallist@4.0.0:
|
||||
resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==}
|
||||
|
||||
yaml@2.9.0:
|
||||
resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==}
|
||||
engines: {node: '>= 14.6'}
|
||||
@@ -3325,6 +3484,18 @@ packages:
|
||||
|
||||
snapshots:
|
||||
|
||||
'@activitypods/refine-providers@0.2.0(@refinedev/core@5.0.12(@tanstack/react-query@5.104.1(react@19.2.7))(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)(web-streams-polyfill@3.3.3)':
|
||||
dependencies:
|
||||
'@refinedev/core': 5.0.12(@tanstack/react-query@5.104.1(react@19.2.7))(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
http-link-header: 1.1.4
|
||||
jsonld: 8.3.3(web-streams-polyfill@3.3.3)
|
||||
jwt-decode: 3.1.2
|
||||
oauth4webapi: 2.17.0
|
||||
optionalDependencies:
|
||||
react: 19.2.7
|
||||
transitivePeerDependencies:
|
||||
- web-streams-polyfill
|
||||
|
||||
'@adobe/css-tools@4.5.0': {}
|
||||
|
||||
'@babel/code-frame@7.29.7':
|
||||
@@ -3557,6 +3728,14 @@ snapshots:
|
||||
|
||||
'@cucumber/tag-expressions@9.1.0': {}
|
||||
|
||||
'@digitalbazaar/http-client@3.4.1(web-streams-polyfill@3.3.3)':
|
||||
dependencies:
|
||||
ky: 0.33.3
|
||||
ky-universal: 0.11.0(ky@0.33.3)(web-streams-polyfill@3.3.3)
|
||||
undici: 5.29.0
|
||||
transitivePeerDependencies:
|
||||
- web-streams-polyfill
|
||||
|
||||
'@emnapi/core@1.11.2':
|
||||
dependencies:
|
||||
'@emnapi/wasi-threads': 1.2.2
|
||||
@@ -3667,6 +3846,8 @@ snapshots:
|
||||
'@esbuild/win32-x64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@fastify/busboy@2.1.1': {}
|
||||
|
||||
'@floating-ui/core@1.8.0':
|
||||
dependencies:
|
||||
'@floating-ui/utils': 0.2.12
|
||||
@@ -4208,6 +4389,41 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/node': 26.1.1
|
||||
|
||||
'@refinedev/core@5.0.12(@tanstack/react-query@5.104.1(react@19.2.7))(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
dependencies:
|
||||
'@refinedev/devtools-internal': 2.0.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@tanstack/react-query': 5.104.1(react@19.2.7)
|
||||
'@types/react': 19.2.17
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.17)
|
||||
lodash: 4.18.1
|
||||
lodash-es: 4.18.1
|
||||
papaparse: 5.7.0
|
||||
pluralize: 8.0.0
|
||||
qs: 6.15.3
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
tslib: 2.8.1
|
||||
warn-once: 0.1.1
|
||||
|
||||
'@refinedev/devtools-internal@2.0.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
dependencies:
|
||||
'@refinedev/devtools-shared': 2.0.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@tanstack/react-query': 5.104.1(react@19.2.7)
|
||||
'@types/react': 19.2.17
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.17)
|
||||
error-stack-parser: 2.1.4
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
|
||||
'@refinedev/devtools-shared@2.0.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
dependencies:
|
||||
'@tanstack/react-query': 5.104.1(react@19.2.7)
|
||||
'@types/react': 19.2.17
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.17)
|
||||
error-stack-parser: 2.1.4
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
|
||||
'@shexjs/parser@1.0.0-alpha.28':
|
||||
dependencies:
|
||||
'@shexjs/util': 1.0.0-alpha.28
|
||||
@@ -4483,6 +4699,13 @@ snapshots:
|
||||
dependencies:
|
||||
'@swc/counter': 0.1.3
|
||||
|
||||
'@tanstack/query-core@5.104.1': {}
|
||||
|
||||
'@tanstack/react-query@5.104.1(react@19.2.7)':
|
||||
dependencies:
|
||||
'@tanstack/query-core': 5.104.1
|
||||
react: 19.2.7
|
||||
|
||||
'@teppeis/multimaps@3.0.0': {}
|
||||
|
||||
'@testing-library/dom@10.4.1':
|
||||
@@ -4721,6 +4944,10 @@ snapshots:
|
||||
|
||||
'@xtuc/long@4.2.2': {}
|
||||
|
||||
abort-controller@3.0.0:
|
||||
dependencies:
|
||||
event-target-shim: 5.0.1
|
||||
|
||||
acorn-import-phases@1.0.4(acorn@8.17.0):
|
||||
dependencies:
|
||||
acorn: 8.17.0
|
||||
@@ -4891,6 +5118,8 @@ snapshots:
|
||||
|
||||
caniuse-lite@1.0.30001805: {}
|
||||
|
||||
canonicalize@1.0.8: {}
|
||||
|
||||
capital-case@1.0.4:
|
||||
dependencies:
|
||||
no-case: 3.0.4
|
||||
@@ -5027,6 +5256,8 @@ snapshots:
|
||||
|
||||
csstype@3.2.3: {}
|
||||
|
||||
data-uri-to-buffer@4.0.1: {}
|
||||
|
||||
debug@4.4.3(supports-color@8.1.1):
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
@@ -5197,6 +5428,8 @@ snapshots:
|
||||
|
||||
esutils@2.0.3: {}
|
||||
|
||||
event-target-shim@5.0.1: {}
|
||||
|
||||
events@3.3.0: {}
|
||||
|
||||
fast-deep-equal@3.1.3: {}
|
||||
@@ -5207,6 +5440,11 @@ snapshots:
|
||||
|
||||
fast-uri@3.1.3: {}
|
||||
|
||||
fetch-blob@3.2.0:
|
||||
dependencies:
|
||||
node-domexception: 1.0.0
|
||||
web-streams-polyfill: 3.3.3
|
||||
|
||||
figures@3.2.0:
|
||||
dependencies:
|
||||
escape-string-regexp: 1.0.5
|
||||
@@ -5266,6 +5504,10 @@ snapshots:
|
||||
mime-types: 2.1.35
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
formdata-polyfill@4.0.10:
|
||||
dependencies:
|
||||
fetch-blob: 3.2.0
|
||||
|
||||
fs-extra@10.1.0:
|
||||
dependencies:
|
||||
graceful-fs: 4.2.11
|
||||
@@ -5512,6 +5754,17 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
|
||||
jsonld@8.3.3(web-streams-polyfill@3.3.3):
|
||||
dependencies:
|
||||
'@digitalbazaar/http-client': 3.4.1(web-streams-polyfill@3.3.3)
|
||||
canonicalize: 1.0.8
|
||||
lru-cache: 6.0.0
|
||||
rdf-canonize: 3.4.0
|
||||
transitivePeerDependencies:
|
||||
- web-streams-polyfill
|
||||
|
||||
jwt-decode@3.1.2: {}
|
||||
|
||||
keyv@4.5.4:
|
||||
dependencies:
|
||||
json-buffer: 3.0.1
|
||||
@@ -5520,6 +5773,16 @@ snapshots:
|
||||
dependencies:
|
||||
seed-random: 2.2.0
|
||||
|
||||
ky-universal@0.11.0(ky@0.33.3)(web-streams-polyfill@3.3.3):
|
||||
dependencies:
|
||||
abort-controller: 3.0.0
|
||||
ky: 0.33.3
|
||||
node-fetch: 3.3.2
|
||||
optionalDependencies:
|
||||
web-streams-polyfill: 3.3.3
|
||||
|
||||
ky@0.33.3: {}
|
||||
|
||||
lines-and-columns@1.2.4: {}
|
||||
|
||||
loader-runner@4.3.2: {}
|
||||
@@ -5532,6 +5795,8 @@ snapshots:
|
||||
dependencies:
|
||||
p-locate: 4.1.0
|
||||
|
||||
lodash-es@4.18.1: {}
|
||||
|
||||
lodash.merge@4.6.2: {}
|
||||
|
||||
lodash.mergewith@4.6.2: {}
|
||||
@@ -5552,6 +5817,10 @@ snapshots:
|
||||
dependencies:
|
||||
yallist: 3.1.1
|
||||
|
||||
lru-cache@6.0.0:
|
||||
dependencies:
|
||||
yallist: 4.0.0
|
||||
|
||||
lucide-react@0.545.0(react@19.2.7):
|
||||
dependencies:
|
||||
react: 19.2.7
|
||||
@@ -5642,10 +5911,18 @@ snapshots:
|
||||
|
||||
node-abort-controller@3.1.1: {}
|
||||
|
||||
node-domexception@1.0.0: {}
|
||||
|
||||
node-fetch@2.7.0:
|
||||
dependencies:
|
||||
whatwg-url: 5.0.0
|
||||
|
||||
node-fetch@3.3.2:
|
||||
dependencies:
|
||||
data-uri-to-buffer: 4.0.1
|
||||
fetch-blob: 3.2.0
|
||||
formdata-polyfill: 4.0.10
|
||||
|
||||
node-releases@2.0.51: {}
|
||||
|
||||
normalize-package-data@8.0.0:
|
||||
@@ -5658,6 +5935,8 @@ snapshots:
|
||||
dependencies:
|
||||
boolbase: 1.0.0
|
||||
|
||||
oauth4webapi@2.17.0: {}
|
||||
|
||||
object-assign@4.1.1: {}
|
||||
|
||||
object-inspect@1.13.4: {}
|
||||
@@ -5738,6 +6017,8 @@ snapshots:
|
||||
dependencies:
|
||||
repeat-string: 1.6.1
|
||||
|
||||
papaparse@5.7.0: {}
|
||||
|
||||
param-case@3.0.4:
|
||||
dependencies:
|
||||
dot-case: 3.0.4
|
||||
@@ -5800,6 +6081,8 @@ snapshots:
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.2
|
||||
|
||||
pluralize@8.0.0: {}
|
||||
|
||||
postcss-modules-extract-imports@3.1.0(postcss@8.5.19):
|
||||
dependencies:
|
||||
postcss: 8.5.19
|
||||
@@ -5866,6 +6149,10 @@ snapshots:
|
||||
|
||||
range-parser@1.3.0: {}
|
||||
|
||||
rdf-canonize@3.4.0:
|
||||
dependencies:
|
||||
setimmediate: 1.0.5
|
||||
|
||||
rdf-data-factory@1.1.3:
|
||||
dependencies:
|
||||
'@rdfjs/types': 1.1.2
|
||||
@@ -6032,6 +6319,8 @@ snapshots:
|
||||
|
||||
semver@7.8.5: {}
|
||||
|
||||
setimmediate@1.0.5: {}
|
||||
|
||||
side-channel-list@1.0.1:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
@@ -6265,6 +6554,10 @@ snapshots:
|
||||
|
||||
undici-types@8.3.0: {}
|
||||
|
||||
undici@5.29.0:
|
||||
dependencies:
|
||||
'@fastify/busboy': 2.1.1
|
||||
|
||||
unicorn-magic@0.4.0: {}
|
||||
|
||||
universalify@2.0.1: {}
|
||||
@@ -6326,10 +6619,14 @@ snapshots:
|
||||
spdx-correct: 3.2.0
|
||||
spdx-expression-parse: 3.0.1
|
||||
|
||||
warn-once@0.1.1: {}
|
||||
|
||||
watchpack@2.5.2:
|
||||
dependencies:
|
||||
graceful-fs: 4.2.11
|
||||
|
||||
web-streams-polyfill@3.3.3: {}
|
||||
|
||||
webidl-conversions@3.0.1: {}
|
||||
|
||||
webidl-conversions@7.0.0: {}
|
||||
@@ -6411,6 +6708,8 @@ snapshots:
|
||||
|
||||
yallist@3.1.1: {}
|
||||
|
||||
yallist@4.0.0: {}
|
||||
|
||||
yaml@2.9.0: {}
|
||||
|
||||
yup@1.7.1:
|
||||
|
||||
File diff suppressed because one or more lines are too long
+10212
-75
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Banked login-flow probe: drives the REAL local ActivityPods stack through the
|
||||
* browser and asserts the whole app-install path — picker → pod login → consent →
|
||||
* authenticated app → create event → create meeting point.
|
||||
*
|
||||
* WHY A SCRIPT, NOT A CUCUMBER STEP. The `@browser` smoke only checks the picker
|
||||
* renders; the consent hop (an OIDC interaction on the pod frontend) and the
|
||||
* app-registration round-trip are what actually proved the stack aligned. This
|
||||
* runs it end-to-end and fails on any UNEXPECTED ≥400, so the regression is
|
||||
* caught without a human driving the browser.
|
||||
*
|
||||
* ORIGIN. The OIDC redirect_uri is registered for `http://localhost:4000/login`
|
||||
* only (the app's FRONT_URL), so the app MUST be served at APP_URL — do not
|
||||
* serve a private instance on an ephemeral port: the pod rejects the redirect.
|
||||
*
|
||||
* The two benign ≥400 classes (the pod notification WebSocket channel, and an
|
||||
* anonymous WAC profile read) are classified, never fatal. Anything else fails.
|
||||
*/
|
||||
|
||||
import { chromium, type Page } from 'playwright';
|
||||
import { podLogin, readResource, type ApodsStackConfig } from '../src/shared/support/activitypodsStack';
|
||||
|
||||
const strip = (u: string): string => u.replace(/\/+$/, '');
|
||||
const BACKEND: string = strip(process.env.BACKEND_URL || 'http://localhost:3003');
|
||||
const APP: string = strip(process.env.APP_URL || 'http://localhost:4000');
|
||||
const config: ApodsStackConfig = {
|
||||
podProviderUrl: strip(process.env.POD_PROVIDER_URL || 'http://localhost:3000'),
|
||||
backendUrl: BACKEND,
|
||||
};
|
||||
|
||||
const USER = `flow${Date.now()}`;
|
||||
const PASS = 'Test1test';
|
||||
const DEADLINE = Date.now() + 8 * 60 * 1000;
|
||||
|
||||
interface HttpError { url: string; status: number; method: string; body: string }
|
||||
|
||||
const redirects: string[] = [];
|
||||
const errors4xx: HttpError[] = [];
|
||||
const consoleErrors: string[] = [];
|
||||
const hops: string[] = [];
|
||||
|
||||
const clean = (s: string): string => s.replace('http://localhost:', ':').replace('http://127.0.0.1:', ':127.');
|
||||
const bodyText = (page: Page): Promise<string> =>
|
||||
page.locator('body').innerText().catch(() => '').then(t => t.replace(/\s+/g, ' ').trim());
|
||||
const snap = (page: Page, name: string): Promise<void> =>
|
||||
page.screenshot({ path: `/tmp/${name}.png`, fullPage: true }).then(() => undefined).catch(() => undefined);
|
||||
|
||||
function wire(page: Page): void {
|
||||
let last = '';
|
||||
page.on('framenavigated', f => {
|
||||
if (f === page.mainFrame() && f.url() !== last) {
|
||||
redirects.push(`${last || '(start)'} -> ${f.url()}`);
|
||||
last = f.url();
|
||||
}
|
||||
});
|
||||
page.on('response', r => {
|
||||
if (r.status() >= 400) {
|
||||
const method = r.request()?.method?.() ?? '?';
|
||||
r.text()
|
||||
.then(b => errors4xx.push({ url: r.url(), status: r.status(), method, body: b.slice(0, 400) }))
|
||||
.catch(() => errors4xx.push({ url: r.url(), status: r.status(), method, body: '(unreadable)' }));
|
||||
}
|
||||
});
|
||||
page.on('console', m => { if (m.type() === 'error') consoleErrors.push(m.text().slice(0, 300)); });
|
||||
page.on('pageerror', e => consoleErrors.push('pageerror: ' + String(e.message).slice(0, 300)));
|
||||
}
|
||||
|
||||
/** Drive picker → pod login → consent until the app shell is reached. */
|
||||
async function driveLogin(page: Page): Promise<boolean> {
|
||||
let authorizeSeen = 0;
|
||||
for (let i = 0; i < 20 && Date.now() < DEADLINE; i++) {
|
||||
const url = page.url();
|
||||
if (hops[hops.length - 1] !== clean(url)) hops.push(clean(url));
|
||||
|
||||
if (url.includes(':5000') && (await page.locator('input[name="username"]').count().catch(() => 0))) {
|
||||
hops.push(' [login] submitting credentials');
|
||||
await page.locator('input[name="username"]').fill(USER).catch(() => {});
|
||||
await page.locator('input[name="password"]').fill(PASS).catch(() => {});
|
||||
const btn = page.getByRole('button', { name: /SE CONNECTER|Se connecter|Sign in|Connexion/i });
|
||||
if (await btn.count()) await btn.first().click().catch(() => {});
|
||||
else await page.locator('button[type=submit]').first().click().catch(() => {});
|
||||
await page.waitForTimeout(3500);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (url.includes(':5000/authorize')) {
|
||||
authorizeSeen++;
|
||||
const acc = page.getByRole('button', { name: /^ACCEPTER$|^Accepter$|^Accept$/i });
|
||||
let visible = await acc.first().isVisible({ timeout: 5000 }).catch(() => false);
|
||||
hops.push(` [authorize #${authorizeSeen}] accept visible=${visible}`);
|
||||
await snap(page, 'login-flow-consent');
|
||||
if (visible) { await acc.first().click().catch(() => {}); hops.push(' [authorize] clicked accept'); }
|
||||
if (authorizeSeen >= 3) { hops.push(' [LOOP] authorize revisited 3x — consent loop'); return false; }
|
||||
await page.waitForTimeout(5000);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (url.startsWith(APP)) {
|
||||
if (url.includes('/login')) { await page.waitForTimeout(5000); continue; }
|
||||
const picker = await page.locator('text=Connectez-vous avec votre Pod').count().catch(() => 0);
|
||||
if (!picker) { hops.push(' [app] shell reached'); return true; }
|
||||
}
|
||||
await page.waitForTimeout(1500);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function createEvent(page: Page): Promise<boolean> {
|
||||
await page.goto(`${APP}/events/new`, { waitUntil: 'domcontentloaded' }).catch(() => {});
|
||||
await page.waitForTimeout(1500);
|
||||
await page.locator('input[placeholder="Donnez un nom à votre événement"]').fill('Concert flow').catch(() => {});
|
||||
const dates = page.locator('input[type="date"]');
|
||||
if (await dates.count()) await dates.first().fill('2026-12-20').catch(() => {});
|
||||
await page.getByRole('button', { name: /Suivant/ }).click().catch(() => {});
|
||||
await page.waitForTimeout(1200);
|
||||
const next = page.getByRole('button', { name: /Suivant/ });
|
||||
if (await next.count()) await next.click().catch(() => {});
|
||||
await page.waitForTimeout(1200);
|
||||
await page.getByRole('button', { name: /Relayer l'événement/ }).click().catch(() => {});
|
||||
await page.waitForTimeout(5000);
|
||||
const u = page.url();
|
||||
return /\/events\/.+/.test(u) && !u.includes('/events/new');
|
||||
}
|
||||
|
||||
async function createMeetingPoint(page: Page): Promise<boolean> {
|
||||
const propose = page.getByRole('button', { name: /Proposer un point de rencontre/i });
|
||||
if (!(await propose.count())) return false;
|
||||
await propose.first().click().catch(() => {});
|
||||
await page.waitForTimeout(1500);
|
||||
await page.locator('input[placeholder="Ex : Gouvernance coopérative — retours SCIC"]').fill('Rdv flow').catch(() => {});
|
||||
await page.getByRole('button', { name: /Créer le point de rencontre/ }).click().catch(() => {});
|
||||
await page.waitForTimeout(5000);
|
||||
const txt = await bodyText(page);
|
||||
return /POINTS DE RENCONTRE|Rdv flow/i.test(txt) && !/pas pu/i.test(txt);
|
||||
}
|
||||
|
||||
/** The two benign ≥400 classes; anything else is a real failure. */
|
||||
function classify(e: HttpError): string | null {
|
||||
if (e.url.includes('/.notifications/WebSocketChannel2023')) return 'websocket-channel 403 (benign)';
|
||||
if (e.status === 403 && e.method === 'GET' && /\/data\//.test(e.url)) return 'WAC profile-read 403 (benign)';
|
||||
return null;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
// Sign in (signup fallback) via the shared helper — the UI login then uses the same creds.
|
||||
await podLogin(config, USER, PASS);
|
||||
|
||||
const browser = await chromium.launch({ headless: true, args: ['--allow-insecure-localhost', '--disable-web-security'] });
|
||||
const ctx = await browser.newContext();
|
||||
const page = await ctx.newPage();
|
||||
wire(page);
|
||||
|
||||
await page.goto(`${APP}/`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(1500);
|
||||
await page.getByRole('button', { name: /Se connecter avec localhost:3000/i }).click();
|
||||
const authenticated = await driveLogin(page);
|
||||
await snap(page, 'login-flow-after-login');
|
||||
const eventCreated = authenticated ? await createEvent(page) : false;
|
||||
await snap(page, 'login-flow-after-event');
|
||||
const meetingPointCreated = eventCreated ? await createMeetingPoint(page) : false;
|
||||
await snap(page, 'login-flow-after-meeting-point');
|
||||
|
||||
await ctx.close();
|
||||
await browser.close();
|
||||
await new Promise(r => setTimeout(r, 1200));
|
||||
|
||||
const fatal = errors4xx.filter(e => classify(e) === null);
|
||||
const benign = errors4xx.map(e => ({ e, label: classify(e) })).filter(x => x.label).map(x => ({ status: x.e.status, url: x.e.url, label: x.label }));
|
||||
const report = {
|
||||
user: USER,
|
||||
authenticated,
|
||||
eventCreated,
|
||||
meetingPointCreated,
|
||||
redirects,
|
||||
hops,
|
||||
benign4xx: benign,
|
||||
fatal4xx: fatal,
|
||||
consoleErrors,
|
||||
ok: authenticated && eventCreated && meetingPointCreated && fatal.length === 0,
|
||||
};
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
process.exit(report.ok ? 0 : 1);
|
||||
})().catch(err => {
|
||||
console.log(JSON.stringify({ ok: false, fatal: String(err).slice(0, 400), redirects, errors4xx, consoleErrors }, null, 2));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -106,7 +106,7 @@ async function main() {
|
||||
fs.mkdirSync(path.dirname(AUTH_STATE_PATH), { recursive: true });
|
||||
fs.writeFileSync(AUTH_STATE_PATH, JSON.stringify(state, null, 2));
|
||||
console.log(`\nAuth state saved to: ${AUTH_STATE_PATH}`);
|
||||
console.log('You can now run: bun run test:data');
|
||||
console.log('Auth state saved. The @data/@e2e suites that consume it are suspended (@suspendu) until the NextGraph workstream resumes.');
|
||||
} catch {
|
||||
console.log('\nCould not save auth state — browser may have closed too quickly.');
|
||||
}
|
||||
|
||||
+96
-417
@@ -1,190 +1,101 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* validate.ts — Validation matrix (broker-level tests only, no @ui/@e2e).
|
||||
* validate.ts — the Festipod validation matrix.
|
||||
*
|
||||
* Default run (no flags) — key subset only, fast:
|
||||
* (a) Polyfill unit tests (@ng-eventually/client — bun test)
|
||||
* (b) Polyfill e2e real-broker (@ng-eventually/client — bun run e2e/run.ts)
|
||||
* (c) Festipod @data KEY SUBSET (cucumber --name regex covering terrain bugs)
|
||||
* (d) Festipod @multibrowser (cucumber --tags @multibrowser)
|
||||
* (e) Festipod @smoke (cucumber --tags @smoke — boot connecté rend)
|
||||
* (f) Festipod @wip [informational only, non-blocking]
|
||||
* SCOPE (2026-10-09, after the ActivityPods-first switch). Two layers are in
|
||||
* the gate:
|
||||
*
|
||||
* With --full flag:
|
||||
* (c) becomes full @data suite (cucumber --tags @data)
|
||||
* (a) Festipod @ui — the UI layer. The `@ui` tag does not exist in this repo;
|
||||
* the layer is exactly the complement of the polyfill/broker tags, and its
|
||||
* command is, verbatim:
|
||||
* node --import tsx/esm node_modules/@cucumber/cucumber/bin/cucumber.js \
|
||||
* --config cucumber.json \
|
||||
* --tags 'not @wip and not @humain and not @data and not @e2e and not @multibrowser'
|
||||
* `cucumber.json` additionally excludes `@suspendu` and `@e2e-activitypods`
|
||||
* (Cucumber ANDs the config tags with the CLI ones), so this stays the
|
||||
* stack-free UI layer exactly as verified at 125 scenarios / 0 failed.
|
||||
*
|
||||
* Each step runs even if the previous one failed (--bail mode is OFF).
|
||||
* Exit code is non-zero if any non-informational step has failures.
|
||||
* (b) Festipod @e2e-activitypods — the smoke over the LOCAL ActivityPods stack
|
||||
* (pod provider + Festipod backend + the app served for real). Distinct
|
||||
* from the suspended `@e2e`: no wallet, no broker, so `cucumber.json`
|
||||
* excludes it. Its config is `cucumber.smoke.json`; its reproducible setup
|
||||
* is the concept note `data-layer`, `cookbook_local-activitypods-platform.md`.
|
||||
* Requires the stack to be UP (the app server and Chromium are started
|
||||
* here); an unreachable stack fails the layer rather than skipping it.
|
||||
*
|
||||
* Profile rotation: both Playwright profiles are rotated before @data and
|
||||
* @multibrowser when their size exceeds BLOAT_THRESHOLD_MB (default 50 MB),
|
||||
* to avoid the sparql_query hang described in caveat_wallet-bloat-hang.
|
||||
* SUSPENDED, NOT RUN HERE (documented skip, code preserved — nothing deleted):
|
||||
* - `@data`, `@e2e`, `@multibrowser`, `@shared-wallet`, `@smoke` scenarios are
|
||||
* polyfill/broker-bound: they sign the shared NextGraph wallet through the
|
||||
* harness (`src/shared/test-harness/harness-ng.tsx`). They are tagged
|
||||
* `@suspendu` in the feature files and excluded there, awaiting the NextGraph
|
||||
* workstream.
|
||||
* - the SDK's own tests (nextgraph/ng-eventually-js) are NOT Festipod's and are
|
||||
* no longer run from here (concept `bdd-testing`,
|
||||
* `rule_tests-validate-festipod-not-the-sdk`).
|
||||
*
|
||||
* A smoke layer that cannot reach its stack FAILS, loudly — it never silently
|
||||
* skips. Exit code is non-zero if any layer fails.
|
||||
*/
|
||||
|
||||
import { spawnSync } from "child_process";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
|
||||
// ─── Config ────────────────────────────────────────────────────────────────
|
||||
|
||||
const FESTIPOD_DIR = "/home/sylvain/projects/festipod/festipod";
|
||||
const POLYFILL_DIR =
|
||||
"/home/sylvain/projects/nextgraph/ng-eventually-js/packages/client";
|
||||
const REPO_DIR = process.cwd();
|
||||
|
||||
const FESTIPOD_PROFILE = path.join(FESTIPOD_DIR, ".playwright-profile");
|
||||
const POLYFILL_PROFILE = path.join(
|
||||
POLYFILL_DIR,
|
||||
"e2e",
|
||||
".playwright-profile-lib",
|
||||
);
|
||||
/** The UI layer, verbatim — see the header. */
|
||||
const UI_TAGS = "not @wip and not @humain and not @data and not @e2e and not @multibrowser";
|
||||
|
||||
/** Rotate profile when it exceeds this many MB (caveat_wallet-bloat-hang). */
|
||||
const BLOAT_THRESHOLD_MB = 50;
|
||||
/** The ActivityPods smoke, minus its documented suspends. */
|
||||
const APODS_TAGS = "@e2e-activitypods and not @suspendu";
|
||||
|
||||
/**
|
||||
* Per-step timeouts:
|
||||
* - polyfill unit/e2e: short steps, keep 10 min
|
||||
* - @data key subset: generous — BeforeAll + 8 scenarios, ~15 min margin
|
||||
* - @data full: full suite, ~35 min margin
|
||||
* - @multibrowser: 7 scenarios, ~10 min margin
|
||||
* - @wip: informational, 10 min
|
||||
*/
|
||||
const TIMEOUT_POLYFILL_UNIT_MS = 10 * 60 * 1000; // 10 min
|
||||
const TIMEOUT_POLYFILL_E2E_MS = 10 * 60 * 1000; // 10 min
|
||||
const TIMEOUT_DATA_KEY_MS = 15 * 60 * 1000; // 15 min (key subset)
|
||||
const TIMEOUT_DATA_FULL_MS = 35 * 60 * 1000; // 35 min (--full)
|
||||
const TIMEOUT_MULTIBROWSER_MS = 10 * 60 * 1000; // 10 min
|
||||
const TIMEOUT_SMOKE_MS = 10 * 60 * 1000; // 10 min (1 @e2e boot scenario)
|
||||
const TIMEOUT_WIP_MS = 10 * 60 * 1000; // 10 min
|
||||
|
||||
/**
|
||||
* Key-subset --name regex: matches exactly the 8 scenarios that cover the
|
||||
* known terrain bugs (inscription, désinscription, isolation, reconnexion,
|
||||
* compteur dérivé, créateur ne participe pas, auth vide, auth distinctes).
|
||||
*
|
||||
* Uses a single cucumber invocation so BeforeAll (broker login) runs once.
|
||||
*
|
||||
* French accent chars must be URL-safe in the regex — cucumber uses JS
|
||||
* RegExp, which handles unicode natively; we pass the literal string.
|
||||
*/
|
||||
const DATA_KEY_NAME_REGEX = [
|
||||
"S'inscrire à un événement",
|
||||
"Se désinscrire d'un événement$",
|
||||
"Une identité fraîche ne voit pas la participation d'une autre",
|
||||
"Une page fraîche pour la même identité relit ses propres données",
|
||||
"Le créateur ne participe pas automatiquement à son événement",
|
||||
"L'inscription fait converger le compteur dérivé du propriétaire",
|
||||
"Un portefeuille connecté est vide par défaut",
|
||||
"Les données du portefeuille sont distinctes des données par défaut",
|
||||
].join("|");
|
||||
const TIMEOUT_UI_MS = 15 * 60 * 1000; // 15 min
|
||||
const TIMEOUT_APODS_MS = 10 * 60 * 1000; // 10 min
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
function dirSizeMB(dir: string): number {
|
||||
if (!fs.existsSync(dir)) return 0;
|
||||
try {
|
||||
const result = spawnSync("du", ["-sm", dir], { encoding: "utf-8" });
|
||||
const line = result.stdout.trim().split("\n")[0] ?? "";
|
||||
return parseInt(line.split("\t")[0] ?? "0", 10);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove any stale Chromium singleton files from `profilePath`. Chromium refuses
|
||||
* to launch (ProcessSingleton error) if SingletonLock, SingletonCookie, or
|
||||
* SingletonSocket are left over from a previous crashed run. Idempotent — safe to
|
||||
* call even when the profile does not exist yet.
|
||||
*/
|
||||
function cleanSingletons(profilePath: string, label: string): void {
|
||||
if (!fs.existsSync(profilePath)) return;
|
||||
const singletons = ["SingletonLock", "SingletonCookie", "SingletonSocket"];
|
||||
for (const name of singletons) {
|
||||
const p = path.join(profilePath, name);
|
||||
if (fs.existsSync(p)) {
|
||||
try {
|
||||
fs.rmSync(p, { force: true });
|
||||
console.log(`[rotate] ${label}: removed stale ${name}.`);
|
||||
} catch {
|
||||
// Non-fatal: if we can't remove it, launch will fail with a clear error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function rotateProfile(profilePath: string, label: string): void {
|
||||
const sizeMB = dirSizeMB(profilePath);
|
||||
if (sizeMB > BLOAT_THRESHOLD_MB) {
|
||||
console.log(
|
||||
`[rotate] ${label}: ${sizeMB}MB > ${BLOAT_THRESHOLD_MB}MB — rotating profile...`,
|
||||
);
|
||||
try {
|
||||
fs.rmSync(profilePath, { recursive: true, force: true });
|
||||
console.log(`[rotate] ${label}: profile removed. Will be recreated.`);
|
||||
} catch (e) {
|
||||
console.warn(`[rotate] ${label}: failed to remove profile: ${e}`);
|
||||
}
|
||||
} else {
|
||||
// Even if we keep the profile, remove any stale Chromium singleton files left
|
||||
// by a previous crashed run — Chromium refuses to launch if they exist.
|
||||
cleanSingletons(profilePath, label);
|
||||
console.log(
|
||||
`[rotate] ${label}: ${sizeMB}MB — below threshold, keeping profile.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface StepResult {
|
||||
label: string;
|
||||
status: "passed" | "failed" | "error";
|
||||
/** Lines to show in the summary (failed scenario names, FAIL lines, etc.) */
|
||||
failures: string[];
|
||||
/** Raw exit code */
|
||||
exitCode: number;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a command and capture its output. Returns the result with parsed
|
||||
* pass/fail summary. Never throws — all errors are captured in StepResult.
|
||||
*/
|
||||
function runStep(
|
||||
label: string,
|
||||
cmd: string,
|
||||
args: string[],
|
||||
cwd: string,
|
||||
timeoutMs: number,
|
||||
extraEnv: Record<string, string> = {},
|
||||
): StepResult {
|
||||
/** The Cucumber invocation every layer shares (same form as the UI gate command). */
|
||||
function cucumberArgs(config: string, tags: string): string[] {
|
||||
return [
|
||||
"--import",
|
||||
"tsx/esm",
|
||||
"node_modules/@cucumber/cucumber/bin/cucumber.js",
|
||||
"--config",
|
||||
config,
|
||||
"--tags",
|
||||
tags,
|
||||
];
|
||||
}
|
||||
|
||||
function runStep(label: string, args: string[], timeoutMs: number): StepResult {
|
||||
const t0 = Date.now();
|
||||
console.log(`\n${"═".repeat(60)}`);
|
||||
console.log(`\n${"═".repeat(64)}`);
|
||||
console.log(`▶ ${label}`);
|
||||
console.log(` ${cmd} ${args.join(" ")} (cwd: ${cwd})`);
|
||||
console.log(` timeout: ${Math.round(timeoutMs / 60000)}min`);
|
||||
console.log(`${"═".repeat(60)}`);
|
||||
console.log(` node ${args.join(" ")}`);
|
||||
console.log(`${"═".repeat(64)}`);
|
||||
|
||||
const env = { ...process.env, ...extraEnv };
|
||||
|
||||
const result = spawnSync(cmd, args, {
|
||||
cwd,
|
||||
env,
|
||||
const result = spawnSync("node", args, {
|
||||
cwd: REPO_DIR,
|
||||
env: process.env,
|
||||
encoding: "utf-8",
|
||||
timeout: timeoutMs,
|
||||
maxBuffer: 20 * 1024 * 1024, // 20MB
|
||||
maxBuffer: 20 * 1024 * 1024,
|
||||
});
|
||||
|
||||
const durationMs = Date.now() - t0;
|
||||
const stdout = result.stdout ?? "";
|
||||
const stderr = result.stderr ?? "";
|
||||
const combined = stdout + "\n" + stderr;
|
||||
|
||||
// Print output in real-time equivalent (post-hoc since spawnSync)
|
||||
if (stdout) process.stdout.write(stdout);
|
||||
if (stderr) process.stderr.write(stderr);
|
||||
const combined = (result.stdout ?? "") + "\n" + (result.stderr ?? "");
|
||||
process.stdout.write(result.stdout ?? "");
|
||||
process.stderr.write(result.stderr ?? "");
|
||||
|
||||
if (result.error) {
|
||||
console.error(`[${label}] process error:`, result.error.message);
|
||||
return {
|
||||
label,
|
||||
status: "error",
|
||||
@@ -193,298 +104,66 @@ function runStep(
|
||||
durationMs,
|
||||
};
|
||||
}
|
||||
|
||||
const exitCode = result.status ?? 1;
|
||||
const failures = extractFailures(combined, label);
|
||||
const status = exitCode === 0 ? "passed" : "failed";
|
||||
|
||||
return { label, status, failures, exitCode, durationMs };
|
||||
return {
|
||||
label,
|
||||
status: (result.status ?? 1) === 0 ? "passed" : "failed",
|
||||
failures: extractFailures(combined),
|
||||
exitCode: result.status ?? 1,
|
||||
durationMs,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract meaningful failure lines from combined stdout+stderr.
|
||||
* Heuristics per step type (cucumber scenario names, FAIL lines, etc.).
|
||||
*/
|
||||
function extractFailures(output: string, label: string): string[] {
|
||||
/** The failure lines worth surfacing: failed scenario names and the summary. */
|
||||
function extractFailures(output: string): string[] {
|
||||
const lines = output.split("\n");
|
||||
const failures: string[] = [];
|
||||
|
||||
if (label.includes("polyfill:unit")) {
|
||||
// bun test output: lines starting with "✗" or "FAIL" or "fail"
|
||||
for (const line of lines) {
|
||||
const l = line.trim();
|
||||
if (/^(✗|✕|FAIL|fail)\s/.test(l) || l.includes("tests failed")) {
|
||||
failures.push(l);
|
||||
}
|
||||
}
|
||||
// Also capture summary line "N passed, M failed"
|
||||
const summary = lines.find(
|
||||
(l) => l.includes("passed") && l.includes("failed"),
|
||||
);
|
||||
if (summary) failures.push(summary.trim());
|
||||
} else if (label.includes("polyfill:e2e")) {
|
||||
// e2e/run.ts output: lines starting with " [FAIL]"
|
||||
for (const line of lines) {
|
||||
const l = line.trim();
|
||||
if (l.startsWith("[FAIL]")) failures.push(l);
|
||||
}
|
||||
// Summary: "N passed / M failed" style
|
||||
const summary = lines.find(
|
||||
(l) => l.includes("passed") || l.includes("failed"),
|
||||
);
|
||||
if (summary && !failures.includes(summary.trim()))
|
||||
failures.push(summary.trim());
|
||||
} else {
|
||||
// Cucumber steps: look for "✗" scenario lines, "FAILED" scenario names,
|
||||
// or lines beginning with "✖" / "×" / "Scenario:" after a failure tag
|
||||
for (const line of lines) {
|
||||
const l = line.trim();
|
||||
if (
|
||||
/^(✗|✕|×|✖)\s/.test(l) ||
|
||||
l.startsWith("✘") ||
|
||||
l.includes("# Scénario:") ||
|
||||
l.includes("# Scenario:") ||
|
||||
(l.startsWith("F") && l.length === 1) // progress-bar failure tick
|
||||
) {
|
||||
if (l.length > 1) failures.push(l);
|
||||
}
|
||||
}
|
||||
// Cucumber "N scenarios (M failed)" summary
|
||||
const summary = lines.find((l) =>
|
||||
/\d+ sc[eé]narios?.*(failed|undefined)/.test(l),
|
||||
);
|
||||
if (summary) failures.push(summary.trim());
|
||||
// Individual scenario fail lines: "✗ Scenario name (features/...)"
|
||||
for (const line of lines) {
|
||||
const l = line.trim();
|
||||
if (l.startsWith("✗") || l.startsWith("✕")) {
|
||||
if (!failures.includes(l)) failures.push(l);
|
||||
}
|
||||
for (const line of lines) {
|
||||
const l = line.trim();
|
||||
if (/^(✗|✕|×|✖|✘)\s/.test(l)) failures.push(l);
|
||||
if (/\d+ sc[eé]narios?.*(failed|undefined)/.test(l) && !failures.includes(l)) {
|
||||
failures.push(l);
|
||||
}
|
||||
}
|
||||
|
||||
return failures.filter(Boolean);
|
||||
}
|
||||
|
||||
function fmtDuration(ms: number): string {
|
||||
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
|
||||
const m = Math.floor(ms / 60_000);
|
||||
const s = ((ms % 60_000) / 1000).toFixed(0);
|
||||
return `${m}m${s}s`;
|
||||
return `${Math.floor(ms / 60_000)}m${((ms % 60_000) / 1000).toFixed(0)}s`;
|
||||
}
|
||||
|
||||
function printMatrix(
|
||||
steps: StepResult[],
|
||||
wipResult: StepResult | null,
|
||||
fullMode: boolean,
|
||||
): void {
|
||||
function printMatrix(steps: StepResult[]): void {
|
||||
console.log("\n");
|
||||
console.log("╔══════════════════════════════════════════════════════════╗");
|
||||
console.log("║ VALIDATION MATRIX ║");
|
||||
if (fullMode) {
|
||||
console.log("║ (mode: --full, @data complet) ║");
|
||||
} else {
|
||||
console.log("║ (mode: défaut, sous-ensemble clé) ║");
|
||||
}
|
||||
console.log("╚══════════════════════════════════════════════════════════╝");
|
||||
console.log("");
|
||||
|
||||
console.log("║ VALIDATION MATRIX ║");
|
||||
console.log("╚══════════════════════════════════════════════════════════╝\n");
|
||||
const maxLabel = Math.max(...steps.map((s) => s.label.length));
|
||||
|
||||
for (const step of steps) {
|
||||
const icon = step.status === "passed" ? "✅" : step.status === "failed" ? "❌" : "⚠️ ";
|
||||
const pad = step.label.padEnd(maxLabel + 2);
|
||||
console.log(` ${icon} ${pad} [${fmtDuration(step.durationMs)}]`);
|
||||
for (const f of step.failures) {
|
||||
console.log(` ↳ ${f}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (wipResult) {
|
||||
console.log("");
|
||||
console.log(" ── @wip (informational, non-blocking) ──────────────────");
|
||||
const icon =
|
||||
wipResult.status === "passed"
|
||||
? "✅"
|
||||
: wipResult.status === "failed"
|
||||
? "❌"
|
||||
: "⚠️ ";
|
||||
const pad = wipResult.label.padEnd(maxLabel + 2);
|
||||
console.log(` ${icon} ${pad} [${fmtDuration(wipResult.durationMs)}]`);
|
||||
for (const f of wipResult.failures) {
|
||||
console.log(` ↳ ${f}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("");
|
||||
const allPassed = steps.every((s) => s.status === "passed");
|
||||
const totalMs = steps.reduce((sum, s) => sum + s.durationMs, 0) +
|
||||
(wipResult?.durationMs ?? 0);
|
||||
if (allPassed) {
|
||||
console.log(" 🟢 ALL STEPS PASSED");
|
||||
} else {
|
||||
const failed = steps.filter((s) => s.status !== "passed");
|
||||
console.log(` 🔴 ${failed.length} STEP(S) FAILED: ${failed.map((s) => s.label).join(", ")}`);
|
||||
}
|
||||
console.log(` ⏱ Total: ${fmtDuration(totalMs)}`);
|
||||
if (!fullMode) {
|
||||
console.log(" ℹ️ Pour @data complet : bun run validate -- --full");
|
||||
console.log(` ${icon} ${step.label.padEnd(maxLabel + 2)} [${fmtDuration(step.durationMs)}]`);
|
||||
for (const f of step.failures) console.log(` ↳ ${f}`);
|
||||
}
|
||||
console.log("");
|
||||
}
|
||||
|
||||
// ─── Cucumber command builder ───────────────────────────────────────────────
|
||||
|
||||
function cucumberArgsByTags(tags: string): string[] {
|
||||
return [
|
||||
"--import",
|
||||
"tsx/esm",
|
||||
"node_modules/.bin/cucumber-js",
|
||||
"--config",
|
||||
"cucumber.json",
|
||||
"--tags",
|
||||
tags,
|
||||
];
|
||||
}
|
||||
|
||||
function cucumberArgsByName(nameRegex: string): string[] {
|
||||
return [
|
||||
"--import",
|
||||
"tsx/esm",
|
||||
"node_modules/.bin/cucumber-js",
|
||||
"--config",
|
||||
"cucumber.json",
|
||||
"--tags",
|
||||
"@data",
|
||||
"--name",
|
||||
nameRegex,
|
||||
];
|
||||
console.log(" ℹ️ @data/@e2e/@multibrowser/@shared-wallet/@smoke : suspendus (workstream NextGraph).");
|
||||
const failed = steps.filter((s) => s.status !== "passed");
|
||||
console.log(failed.length === 0
|
||||
? " 🟢 ALL LAYERS PASSED"
|
||||
: ` 🔴 ${failed.length} LAYER(S) FAILED: ${failed.map((s) => s.label).join(", ")}`);
|
||||
console.log(` ⏱ Total: ${fmtDuration(steps.reduce((sum, s) => sum + s.durationMs, 0))}`);
|
||||
console.log("");
|
||||
}
|
||||
|
||||
// ─── Main ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = process.argv.slice(2);
|
||||
const fullMode = args.includes("--full");
|
||||
|
||||
if (fullMode) {
|
||||
console.log("🔍 Festipod — Full Validation Run (--full : @data complet)");
|
||||
} else {
|
||||
console.log("🔍 Festipod — Validation Run (sous-ensemble clé)");
|
||||
console.log(" Pour @data complet : bun run validate -- --full");
|
||||
}
|
||||
console.log(` Festipod: ${FESTIPOD_DIR}`);
|
||||
console.log(` Polyfill: ${POLYFILL_DIR}`);
|
||||
console.log("");
|
||||
|
||||
// ── Profile rotation AVANT les étapes broker ──────────────────────────────
|
||||
console.log("── Profile rotation check (avant @data et @multibrowser) ────");
|
||||
rotateProfile(FESTIPOD_PROFILE, "festipod");
|
||||
rotateProfile(POLYFILL_PROFILE, "polyfill-lib");
|
||||
function main(): void {
|
||||
console.log(`🔍 Festipod — validation (repo: ${REPO_DIR})`);
|
||||
|
||||
const steps: StepResult[] = [];
|
||||
steps.push(runStep("festipod:@ui", cucumberArgs("cucumber.json", UI_TAGS), TIMEOUT_UI_MS));
|
||||
steps.push(runStep("festipod:@e2e-activitypods", cucumberArgs("cucumber.smoke.json", APODS_TAGS), TIMEOUT_APODS_MS));
|
||||
|
||||
// ── (a) Polyfill unit tests ───────────────────────────────────────────────
|
||||
steps.push(
|
||||
runStep(
|
||||
"polyfill:unit",
|
||||
"bun",
|
||||
["test"],
|
||||
POLYFILL_DIR,
|
||||
TIMEOUT_POLYFILL_UNIT_MS,
|
||||
),
|
||||
);
|
||||
|
||||
// ── (b) Polyfill e2e real broker ──────────────────────────────────────────
|
||||
// Clean singleton files immediately before launching Chromium — guards against
|
||||
// any file left by polyfill:unit (unlikely but defensive) or by a previous
|
||||
// interrupted run that the initial rotateProfile call ran before.
|
||||
cleanSingletons(POLYFILL_PROFILE, "polyfill-lib (pre-e2e)");
|
||||
steps.push(
|
||||
runStep(
|
||||
"polyfill:e2e",
|
||||
"bun",
|
||||
["run", "e2e/run.ts"],
|
||||
POLYFILL_DIR,
|
||||
TIMEOUT_POLYFILL_E2E_MS,
|
||||
),
|
||||
);
|
||||
|
||||
// ── (c) Festipod @data ────────────────────────────────────────────────────
|
||||
if (fullMode) {
|
||||
// --full : lance tout @data
|
||||
steps.push(
|
||||
runStep(
|
||||
"festipod:@data (complet)",
|
||||
"node",
|
||||
cucumberArgsByTags("@data"),
|
||||
FESTIPOD_DIR,
|
||||
TIMEOUT_DATA_FULL_MS,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// défaut : sous-ensemble clé en UNE invocation (BeforeAll partagé)
|
||||
steps.push(
|
||||
runStep(
|
||||
"festipod:@data (clé)",
|
||||
"node",
|
||||
cucumberArgsByName(DATA_KEY_NAME_REGEX),
|
||||
FESTIPOD_DIR,
|
||||
TIMEOUT_DATA_KEY_MS,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── (d) Festipod @multibrowser ────────────────────────────────────────────
|
||||
// Exclude @wip: a scenario tagged @wip @multibrowser (e.g. the reactive
|
||||
// cross-session scenario, blocked by the shared-wallet structural limit) must
|
||||
// not gate the baseline — it flows into the informational @wip pass below.
|
||||
steps.push(
|
||||
runStep(
|
||||
"festipod:@multibrowser",
|
||||
"node",
|
||||
cucumberArgsByTags("@multibrowser and not @wip"),
|
||||
FESTIPOD_DIR,
|
||||
TIMEOUT_MULTIBROWSER_MS,
|
||||
),
|
||||
);
|
||||
|
||||
// ── (e) Festipod @smoke — boot connecté rend / page blanche ───────────────
|
||||
// Un seul scénario @e2e : boote le VRAI App, se connecte au broker, et vérifie
|
||||
// que l'accueil connecté rend du contenu d'app réel SANS erreur runtime. Garde
|
||||
// la CLASSE « crash de rendu une fois connecté » (page blanche). On ne lance
|
||||
// QUE @smoke (pas tout @e2e) pour garder le run par défaut rapide.
|
||||
// Nettoie les singletons Chromium juste avant, comme les autres étapes broker.
|
||||
cleanSingletons(FESTIPOD_PROFILE, "festipod (pre-@smoke)");
|
||||
steps.push(
|
||||
runStep(
|
||||
"festipod:@smoke",
|
||||
"node",
|
||||
cucumberArgsByTags("@smoke and not @wip"),
|
||||
FESTIPOD_DIR,
|
||||
TIMEOUT_SMOKE_MS,
|
||||
),
|
||||
);
|
||||
|
||||
// ── (f) Festipod @wip [informational] ────────────────────────────────────
|
||||
console.log("\n── @wip informational pass (non-blocking) ──────────────────");
|
||||
const wipResult = runStep(
|
||||
"festipod:@wip",
|
||||
"node",
|
||||
cucumberArgsByTags("@wip"),
|
||||
FESTIPOD_DIR,
|
||||
TIMEOUT_WIP_MS,
|
||||
);
|
||||
|
||||
// ── Matrix ────────────────────────────────────────────────────────────────
|
||||
printMatrix(steps, wipResult, fullMode);
|
||||
|
||||
// ── Exit code ─────────────────────────────────────────────────────────────
|
||||
const anyFailed = steps.some((s) => s.status !== "passed");
|
||||
process.exit(anyFailed ? 1 : 0);
|
||||
printMatrix(steps);
|
||||
process.exit(steps.some((s) => s.status !== "passed") ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error("validate.ts: unhandled error:", e);
|
||||
process.exit(1);
|
||||
});
|
||||
main();
|
||||
|
||||
+39
-13
@@ -1,9 +1,11 @@
|
||||
import { RouterProvider, useRouter } from './router';
|
||||
import { useEffect } from 'react';
|
||||
import { RouterProvider, useNavigate, useRouter } from './router';
|
||||
import { ThemeProvider } from '../shared/context/ThemeContext';
|
||||
import { NextGraphProvider } from '../shared/context/NextGraphContext';
|
||||
import { FestipodDataProvider } from '../shared/context/FestipodDataContext';
|
||||
import { AuthGate } from './AuthGate';
|
||||
import { ToastContainer } from '../shared/components/sketchy';
|
||||
import { initialBackendName } from '../shared/refine/providers';
|
||||
|
||||
// Auth
|
||||
import { WelcomeScreen } from '../modules/auth/screens/WelcomeScreen';
|
||||
@@ -37,6 +39,7 @@ function AppContent() {
|
||||
case 'home': return <HomeScreen />;
|
||||
case 'events': return <EventsScreen />;
|
||||
case 'create-event': return <CreateEventScreen />;
|
||||
case 'login': return <AuthenticatedLoginLanding />;
|
||||
case 'event-detail': return <EventDetailScreen />;
|
||||
case 'update-event': return <UpdateEventScreen />;
|
||||
case 'invite': return <InviteScreen />;
|
||||
@@ -52,21 +55,44 @@ function AppContent() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What `/login` shows once the gate has let the user through: home. The route
|
||||
* exists for the Solid-OIDC callback (`AuthGate` intercepts `?code` and
|
||||
* `?register_app` before children render); a plain `/login` with nothing left
|
||||
* to do is not a destination — send the user to the app.
|
||||
*/
|
||||
function AuthenticatedLoginLanding() {
|
||||
const navigate = useNavigate();
|
||||
useEffect(() => {
|
||||
navigate('/home');
|
||||
}, [navigate]);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const backendName = initialBackendName();
|
||||
const isNg = backendName === 'nextgraph';
|
||||
|
||||
// THE AUTH GATE ABOVE THE DATA PROVIDER — signing in must have settled before
|
||||
// any data path mounts. The gate renders children only once there is a
|
||||
// session to read through (or an explicit offline-demo choice), so the data
|
||||
// provider's dispatch decision never races a sign-in that has not finished.
|
||||
const appTree = (
|
||||
<AuthGate>
|
||||
<FestipodDataProvider>
|
||||
<div className="app-container">
|
||||
<AppContent />
|
||||
<ToastContainer />
|
||||
</div>
|
||||
</FestipodDataProvider>
|
||||
</AuthGate>
|
||||
);
|
||||
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<NextGraphProvider>
|
||||
<FestipodDataProvider>
|
||||
<RouterProvider>
|
||||
<div className="app-container">
|
||||
<AuthGate>
|
||||
<AppContent />
|
||||
</AuthGate>
|
||||
<ToastContainer />
|
||||
</div>
|
||||
</RouterProvider>
|
||||
</FestipodDataProvider>
|
||||
</NextGraphProvider>
|
||||
<RouterProvider>
|
||||
{isNg ? <NextGraphProvider>{appTree}</NextGraphProvider> : appTree}
|
||||
</RouterProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
+243
-46
@@ -1,83 +1,228 @@
|
||||
/**
|
||||
* AuthGate — the ONE await the application makes before it renders.
|
||||
*
|
||||
* Signing in is `ensureIdentity()` and nothing else: it takes no identifier,
|
||||
* resolves who we are and does the connection work (restoring what others shared
|
||||
* with us, draining our inboxes). Whatever a user has to see or do while that
|
||||
* resolves — opening the wallet, loading it onto a first-time device — belongs to
|
||||
* the SDK: it mounts a full-screen barrier of its own on every top-level load and
|
||||
* takes it down itself, and it owns the return from the broker round-trip (the
|
||||
* barrier comes back prefilled, and confirming it hands the page over a second
|
||||
* time; our page is never reloaded and nothing outside the barrier is touched).
|
||||
* Festipod renders no access screen of its own and re-drives nothing.
|
||||
* Signing in is backend-specific. There are three paths in this gate:
|
||||
*
|
||||
* TWO CALLS, IN ORDER, AND THE ORDER IS CONTRACTUAL: start the session (`init`),
|
||||
* then await `ensureIdentity()`. A session arrives only through `init`, and
|
||||
* `ensureIdentity()` awaited before it has been called THROWS. The order is a fact
|
||||
* of the statement sequence below, not of React's effect ordering — which would
|
||||
* get it wrong: this gate's effect runs BEFORE its parent provider's.
|
||||
* 1. ACTIVITYPODS (default variant). The app renders the Solid-OIDC flow itself:
|
||||
* a Pod-provider picker when no session exists, and the `/login` route handling
|
||||
* the OAuth callback plus the ActivityPods app-registration stage. The auth
|
||||
* provider is built with the app description document served by our backend as
|
||||
* `clientId` and `${origin}/login` as `redirectUri`. Every session the flow
|
||||
* establishes is published to `utils/currentSession` — the module store the
|
||||
* data provider's dispatch reads, independent of where each mounts in the tree.
|
||||
*
|
||||
* It hands back WHO WE ARE, and this is the app's only upstream answer to that
|
||||
* question — everything else it knows about the user it has to read first. The
|
||||
* value is published for display (`shared/utils/currentPrincipal`) and goes
|
||||
* nowhere near a data call: no call takes an identity, because the session
|
||||
* already belongs to one user.
|
||||
* 2. NEXTGRAPH (suspended at the root, kept for the URL variant). The legacy
|
||||
* `ensureIdentity()` flow: the SDK mounts its own full-screen barrier and owns
|
||||
* the broker round-trip.
|
||||
*
|
||||
* Nothing of the app renders before that await settles: a screen mounted earlier
|
||||
* would read as an identity that is not yet settled.
|
||||
* 3. LOCAL DEMO. When no ActivityPods backend is configured, or when the user
|
||||
* explicitly chooses to continue offline, children render without an
|
||||
* authenticated session and `FestipodDataProvider` serves the seeded demo data.
|
||||
*
|
||||
* AND NOTHING RENDERS IF IT FAILS. A rejected `ensureIdentity()` is not a mode the
|
||||
* app degrades through: an app that could not sign in but still shows its screens
|
||||
* is indistinguishable from an app whose user simply owns nothing — a total
|
||||
* failure wearing the face of an empty account. So the rejection is SHOWN, and the
|
||||
* children stay unmounted, which is also what keeps the data layer from settling
|
||||
* on its empty stand-in provider for the rest of the session.
|
||||
* Whatever a user has to see or do while signing in belongs on screen: the
|
||||
* ActivityPods picker replaces the old wallet barrier, and the local demo fallback
|
||||
* remains reachable.
|
||||
*/
|
||||
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import { useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import { ensureIdentity } from '@ng-eventually/polyfill';
|
||||
import { startNgSession } from '../shared/utils/ngSession';
|
||||
import { setCurrentPrincipal } from '../shared/utils/currentPrincipal';
|
||||
import { setCurrentSession } from '../shared/utils/currentSession';
|
||||
import type { AuthProvider } from '@activitypods/refine-providers';
|
||||
import { useRouter, useNavigate } from './router';
|
||||
import { initialBackendName } from '../shared/refine/providers';
|
||||
import { getBackendUrl } from '../shared/utils/runtimeConfig';
|
||||
import {
|
||||
buildActivityPodsAuthProvider,
|
||||
parseAuthFlowStage,
|
||||
safeRedirectPath,
|
||||
} from './auth/activityPodsAuth';
|
||||
import { PodProviderPicker } from './auth/PodProviderPicker';
|
||||
|
||||
type ActivityPodsAuthState = 'checking' | 'authenticated' | 'unauthenticated' | 'error';
|
||||
|
||||
type CallbackResult = Awaited<ReturnType<AuthProvider['handleCallback']>>;
|
||||
|
||||
/**
|
||||
* The in-flight (or settled) `handleCallback()` exchange, keyed by the `?code=`
|
||||
* it belongs to. React's StrictMode double-invokes effects in dev and the
|
||||
* authorization code is single-use: a second exchange would race the first, throw
|
||||
* on the loser, and show the fatal screen after a successful login. Both effect
|
||||
* runs share this one promise, so the exchange happens exactly once; only the
|
||||
* live run publishes its result. Scoped to the code value so a genuinely new
|
||||
* login (a new code) is not mistaken for a re-entry.
|
||||
*/
|
||||
let callbackExchange: { code: string; promise: Promise<CallbackResult> } | undefined;
|
||||
|
||||
/**
|
||||
* Drop the OIDC callback parameters once the exchange has run. The code is
|
||||
* consumed; leaving it in the address bar lets a later effect re-run — or a
|
||||
* reload — treat the page as a fresh callback.
|
||||
*/
|
||||
function stripCallbackParams(): void {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete('code');
|
||||
url.searchParams.delete('state');
|
||||
url.searchParams.delete('session_state');
|
||||
window.history.replaceState(null, '', `${url.pathname}${url.search}${url.hash}`);
|
||||
}
|
||||
|
||||
export function AuthGate({ children }: { children: ReactNode }) {
|
||||
const { route } = useRouter();
|
||||
const navigate = useNavigate();
|
||||
// Whether the ONE identity await has resolved.
|
||||
|
||||
const backendName = initialBackendName();
|
||||
const isActivityPods = backendName === 'activitypods';
|
||||
const isNg = backendName === 'nextgraph';
|
||||
const activityPodsEnabled = isActivityPods && !!getBackendUrl();
|
||||
const [demoMode, setDemoMode] = useState(false);
|
||||
|
||||
// Whether the ONE identity await has resolved (NextGraph path).
|
||||
const [identityReady, setIdentityReady] = useState(false);
|
||||
// Why it did NOT resolve. Set once, never cleared: signing in is attempted once.
|
||||
const [signInError, setSignInError] = useState<string | null>(null);
|
||||
|
||||
// ActivityPods flow state.
|
||||
const [apState, setApState] = useState<ActivityPodsAuthState>('checking');
|
||||
const [apError, setApError] = useState<string | null>(null);
|
||||
|
||||
const apAuthProvider = useMemo(() => {
|
||||
if (!activityPodsEnabled) return undefined;
|
||||
return buildActivityPodsAuthProvider(window.location.origin);
|
||||
}, [activityPodsEnabled]);
|
||||
|
||||
// NextGraph identity flow — unchanged.
|
||||
useEffect(() => {
|
||||
if (!isNg) return;
|
||||
let cancelled = false;
|
||||
// FIRST — a session arrives only through the SDK's `init`. Idempotent, so the
|
||||
// provider above may have started it already; what matters is that it has been
|
||||
// called before the await below, or the await throws.
|
||||
void startNgSession();
|
||||
void ensureIdentity()
|
||||
.then(principal => {
|
||||
// Publish who we are BEFORE anything renders — the identity is a fact of
|
||||
// the session, not state of this component, so it is recorded even if the
|
||||
// effect was torn down in between.
|
||||
.then((principal) => {
|
||||
setCurrentPrincipal(principal);
|
||||
if (!cancelled) setIdentityReady(true);
|
||||
})
|
||||
.catch(err => {
|
||||
.catch((err) => {
|
||||
console.error('[Auth] ensureIdentity failed:', err);
|
||||
if (!cancelled) setSignInError(err instanceof Error ? err.message : String(err));
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
}, [isNg]);
|
||||
|
||||
// ActivityPods Solid-OIDC + app-registration flow.
|
||||
useEffect(() => {
|
||||
if (!activityPodsEnabled || !apAuthProvider || demoMode) return;
|
||||
const provider = apAuthProvider;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
async function run() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const stage = parseAuthFlowStage(window.location.search);
|
||||
// Captured before the exchange so stripping the params cannot erase what
|
||||
// the failure decision below needs.
|
||||
const code = params.get('code');
|
||||
const hadFlowState = params.has('state');
|
||||
|
||||
try {
|
||||
if (stage.stage === 'callback') {
|
||||
// One exchange per code: the StrictMode double-run (both mounts see the
|
||||
// same `?code=`) shares the in-flight promise instead of racing.
|
||||
if (!callbackExchange || callbackExchange.code !== code) {
|
||||
callbackExchange = { code: code ?? '', promise: provider.handleCallback() };
|
||||
}
|
||||
const exchange = callbackExchange;
|
||||
try {
|
||||
const result = await exchange.promise;
|
||||
stripCallbackParams();
|
||||
if (cancelled) return;
|
||||
const session = provider.getSession();
|
||||
setCurrentPrincipal(result.webId);
|
||||
setCurrentSession(session ? { webId: session.webId, token: session.token } : undefined);
|
||||
// The contract's callback result carries where to return to; honour
|
||||
// it (validated) rather than trusting only the parsed URL.
|
||||
const redirect = safeRedirectPath(result.redirect ?? stage.redirectPath);
|
||||
navigate(`/login?register_app=1&redirect=${encodeURIComponent(redirect)}`);
|
||||
return;
|
||||
} catch (err) {
|
||||
stripCallbackParams();
|
||||
if (cancelled) return;
|
||||
// A `?code=` with no flow state — a stale bookmark, any link that
|
||||
// happens to carry the param — is not a callback the provider can
|
||||
// complete: it had nothing to exchange. Degrade to the picker rather
|
||||
// than the fatal screen. A callback that DID carry flow state (a
|
||||
// `state` param) stays a visible error: it is a real attempt the
|
||||
// provider refused.
|
||||
if (!hadFlowState) {
|
||||
setCurrentSession(undefined);
|
||||
setApState('unauthenticated');
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
if (callbackExchange === exchange) callbackExchange = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (stage.stage === 'register_app') {
|
||||
const session = provider.getSession();
|
||||
if (!session) {
|
||||
// No live session at this stage — a stale `?register_app=1` link, or
|
||||
// a token that has expired. There is nothing to register; show the
|
||||
// picker instead of stalling on a permanent blank `checking` screen.
|
||||
setCurrentSession(undefined);
|
||||
if (!cancelled) setApState('unauthenticated');
|
||||
return;
|
||||
}
|
||||
const registration = await provider.registerApp(session.webId);
|
||||
if (cancelled) return;
|
||||
if (registration) {
|
||||
setCurrentPrincipal(session.webId);
|
||||
setCurrentSession({ webId: session.webId, token: session.token });
|
||||
navigate(stage.redirectPath);
|
||||
}
|
||||
// If `registerApp` returned `undefined`, the browser left for the Pod's
|
||||
// consent screen. The effect is torn down; it will resume when the user
|
||||
// comes back.
|
||||
return;
|
||||
}
|
||||
|
||||
const session = provider.getSession();
|
||||
if (session) {
|
||||
setCurrentPrincipal(session.webId);
|
||||
setCurrentSession({ webId: session.webId, token: session.token });
|
||||
if (!cancelled) setApState('authenticated');
|
||||
} else {
|
||||
setCurrentSession(undefined);
|
||||
if (!cancelled) setApState('unauthenticated');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Auth] ActivityPods flow failed:', err);
|
||||
if (!cancelled) {
|
||||
setApError(err instanceof Error ? err.message : String(err));
|
||||
setApState('error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void run();
|
||||
return () => { cancelled = true; };
|
||||
}, [activityPodsEnabled, apAuthProvider, demoMode, navigate, route.page]);
|
||||
|
||||
// Once identified, leave the welcome screen for the app home.
|
||||
useEffect(() => {
|
||||
if (apState === 'authenticated' && route.page === 'welcome') {
|
||||
navigate('/home');
|
||||
}
|
||||
}, [apState, route.page, navigate]);
|
||||
|
||||
// Once identified, leave the disconnected welcome screen for the app home.
|
||||
useEffect(() => {
|
||||
if (identityReady && route.page === 'welcome') {
|
||||
navigate('/home');
|
||||
}
|
||||
}, [identityReady, route.page, navigate]);
|
||||
|
||||
// Signing in FAILED — say so. The app has nothing legitimate to show, and
|
||||
// showing it anyway would pass a broken session off as an empty one.
|
||||
// Signing in FAILED — say so. The app has nothing legitimate to show.
|
||||
if (signInError) {
|
||||
return (
|
||||
<div id="auth-error" role="alert" className="app-card" style={{ margin: '2rem 1rem' }}>
|
||||
@@ -91,13 +236,65 @@ export function AuthGate({ children }: { children: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
// Signing in is not settled yet. Render NOTHING — the SDK's own full-screen
|
||||
// barrier is what is on screen, it put it there and it takes it down. Anything
|
||||
// of ours here would be a second thing competing with it.
|
||||
if (!identityReady) {
|
||||
if (apError) {
|
||||
return (
|
||||
<div id="auth-error" role="alert" className="app-card" style={{ margin: '2rem 1rem' }}>
|
||||
<h1 className="app-title">Connexion impossible</h1>
|
||||
<p className="app-text">
|
||||
Festipod n’a pas réussi à se connecter à votre Pod. Vérifiez l’URL du
|
||||
provider et réessayez.
|
||||
</p>
|
||||
<p className="app-text" data-testid="auth-error-detail">{apError}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="app-btn"
|
||||
onClick={() => {
|
||||
setApError(null);
|
||||
setApState('unauthenticated');
|
||||
}}
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
Retour
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ActivityPods variant: show the picker until the user has a session.
|
||||
if (isActivityPods && activityPodsEnabled && !demoMode) {
|
||||
if (apState === 'checking') {
|
||||
return null;
|
||||
}
|
||||
if (apState === 'unauthenticated') {
|
||||
// Seed the post-login destination: an explicit (validated) `?redirect=`
|
||||
// wins; otherwise a deep link the user opened lands them back on that page
|
||||
// once signed in, instead of dropping everyone on `/home`. Login and the
|
||||
// bare welcome route are not deep links.
|
||||
const hasExplicitRedirect = new URLSearchParams(window.location.search).has('redirect');
|
||||
const deepLink =
|
||||
route.page !== 'login' && route.page !== 'welcome' ? window.location.pathname : undefined;
|
||||
const loginRedirect = hasExplicitRedirect
|
||||
? parseAuthFlowStage(window.location.search).redirectPath
|
||||
: deepLink ?? '/home';
|
||||
return (
|
||||
<PodProviderPicker
|
||||
authProvider={apAuthProvider!}
|
||||
redirect={loginRedirect}
|
||||
onDemo={() => {
|
||||
setDemoMode(true);
|
||||
navigate('/home');
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
// authenticated → fall through to children
|
||||
}
|
||||
|
||||
// NextGraph variant: the SDK's own full-screen barrier is what is on screen.
|
||||
if (isNg && !identityReady) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// The app.
|
||||
// The app (local demo or authenticated ActivityPods / NextGraph).
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useState } from 'react';
|
||||
import type { AuthProvider } from '@activitypods/refine-providers';
|
||||
import { Button, Input, Title, Text } from '../../shared/components/sketchy';
|
||||
import { resolveActivityPodsAuthConfig } from './activityPodsAuth';
|
||||
|
||||
interface PodProviderPickerProps {
|
||||
authProvider: AuthProvider;
|
||||
/**
|
||||
* Where to send the user once the flow completes. The gate computes it —
|
||||
* a validated `?redirect=` value, or the route the user was trying to reach —
|
||||
* so the picker never reads the raw URL itself.
|
||||
*/
|
||||
redirect: string;
|
||||
onDemo?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pod-provider picker — the ActivityPods sign-in screen.
|
||||
*
|
||||
* When a default Pod provider is configured via `VITE_POD_PROVIDER_URL`, it is
|
||||
* offered as a one-click option. The user can also type any Solid Pod provider
|
||||
* URL. A "continue offline" button keeps the local demo fallback reachable.
|
||||
*/
|
||||
export function PodProviderPicker({ authProvider, redirect, onDemo }: PodProviderPickerProps) {
|
||||
const { podProviderUrl } = resolveActivityPodsAuthConfig(window.location.origin);
|
||||
const [customUrl, setCustomUrl] = useState('');
|
||||
|
||||
const startLogin = (issuer: string) => {
|
||||
void authProvider.login({ issuer, redirect });
|
||||
};
|
||||
|
||||
const defaultProviderHost = podProviderUrl ? safeHost(podProviderUrl) : undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: 24,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
minHeight: '100dvh',
|
||||
// Full-width buttons are right on mobile; on desktop cap the column so
|
||||
// the sign-in screen reads as a centered card instead of a full-bleed row.
|
||||
maxWidth: 480,
|
||||
margin: '0 auto',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Title style={{ textAlign: 'center', fontSize: 32, marginBottom: 16 }}>
|
||||
Festipod
|
||||
</Title>
|
||||
<Text
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
fontSize: 16,
|
||||
marginBottom: 32,
|
||||
lineHeight: 1.5,
|
||||
color: '#555',
|
||||
}}
|
||||
>
|
||||
Connectez-vous avec votre Pod pour découvrir et créer des points de rencontre.
|
||||
</Text>
|
||||
|
||||
{defaultProviderHost && (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => startLogin(podProviderUrl!)}
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
Se connecter avec {defaultProviderHost}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Input
|
||||
type="url"
|
||||
placeholder="URL de votre Pod provider"
|
||||
value={customUrl}
|
||||
onChange={(e) => setCustomUrl(e.target.value)}
|
||||
style={{ marginBottom: 8, width: '100%' }}
|
||||
/>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => customUrl && startLogin(customUrl)}
|
||||
disabled={!customUrl}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
Continuer
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{onDemo && (
|
||||
<Button variant="accent-outline" onClick={onDemo} style={{ marginBottom: 12 }}>
|
||||
Continuer hors ligne
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Text style={{ textAlign: 'center', fontSize: 12, color: '#bbb' }}>
|
||||
Version beta
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function safeHost(url: string): string {
|
||||
try {
|
||||
return new URL(url).host;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { setRuntimeConfig } from '../../shared/utils/runtimeConfig';
|
||||
import {
|
||||
parseAuthFlowStage,
|
||||
resolveActivityPodsAuthConfig,
|
||||
type AuthFlowStage,
|
||||
} from './activityPodsAuth';
|
||||
|
||||
test('clientId uses explicit backendClientId when set', () => {
|
||||
setRuntimeConfig({ backendClientId: 'https://app.example/id.json' });
|
||||
const cfg = resolveActivityPodsAuthConfig('http://localhost:3000');
|
||||
expect(cfg.clientId).toBe('https://app.example/id.json');
|
||||
expect(cfg.redirectUri).toBe('http://localhost:3000/login');
|
||||
});
|
||||
|
||||
test('clientId derives the actor uri from backendUrl when backendClientId is empty', () => {
|
||||
setRuntimeConfig({ backendUrl: 'http://localhost:3003', backendClientId: '' });
|
||||
const cfg = resolveActivityPodsAuthConfig('http://localhost:5000');
|
||||
expect(cfg.clientId).toBe('http://localhost:3003/as/actor/app');
|
||||
expect(cfg.redirectUri).toBe('http://localhost:5000/login');
|
||||
});
|
||||
|
||||
test('clientId falls back to the origin actor uri when nothing is configured', () => {
|
||||
setRuntimeConfig({});
|
||||
const cfg = resolveActivityPodsAuthConfig('http://localhost:5000');
|
||||
expect(cfg.clientId).toBe('http://localhost:5000/as/actor/app');
|
||||
});
|
||||
|
||||
test('clientId does not double the slash on a trailing-slash backendUrl', () => {
|
||||
setRuntimeConfig({ backendUrl: 'http://localhost:3003/' });
|
||||
const cfg = resolveActivityPodsAuthConfig('http://localhost:5000');
|
||||
expect(cfg.clientId).toBe('http://localhost:3003/as/actor/app');
|
||||
});
|
||||
|
||||
test('podProviderUrl is passed through when set', () => {
|
||||
setRuntimeConfig({ podProviderUrl: 'http://localhost:3000' });
|
||||
const cfg = resolveActivityPodsAuthConfig('http://localhost:5000');
|
||||
expect(cfg.podProviderUrl).toBe('http://localhost:3000');
|
||||
});
|
||||
|
||||
test('whitespace-only runtime values are treated as empty', () => {
|
||||
setRuntimeConfig({
|
||||
backendUrl: ' ',
|
||||
backendClientId: ' ',
|
||||
podProviderUrl: ' ',
|
||||
});
|
||||
const cfg = resolveActivityPodsAuthConfig('http://localhost:5000');
|
||||
expect(cfg.clientId).toBe('http://localhost:5000/as/actor/app');
|
||||
expect(cfg.podProviderUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
test('parseAuthFlowStage detects OIDC callback', () => {
|
||||
expect(parseAuthFlowStage('?code=abc&state=xyz')).toEqual({
|
||||
stage: 'callback',
|
||||
redirectPath: '/home',
|
||||
});
|
||||
expect(parseAuthFlowStage('?code=abc&redirect=/events')).toEqual({
|
||||
stage: 'callback',
|
||||
redirectPath: '/events',
|
||||
});
|
||||
});
|
||||
|
||||
test('parseAuthFlowStage detects app registration stage', () => {
|
||||
expect(parseAuthFlowStage('?register_app=1')).toEqual({
|
||||
stage: 'register_app',
|
||||
redirectPath: '/home',
|
||||
});
|
||||
expect(parseAuthFlowStage('?register_app=1&redirect=/profile')).toEqual({
|
||||
stage: 'register_app',
|
||||
redirectPath: '/profile',
|
||||
});
|
||||
});
|
||||
|
||||
test('parseAuthFlowStage defaults to picker', () => {
|
||||
expect(parseAuthFlowStage('')).toEqual({ stage: 'picker', redirectPath: '/home' });
|
||||
// A bare `?redirect` does not change the stage; it is carried (validated) so
|
||||
// every stage exposes the same post-login destination.
|
||||
expect(parseAuthFlowStage('?redirect=/events')).toEqual({
|
||||
stage: 'picker',
|
||||
redirectPath: '/events',
|
||||
});
|
||||
});
|
||||
|
||||
test('parseAuthFlowStage rejects non-same-origin redirects', () => {
|
||||
// Absolute, protocol-relative, and backslash spellings all resolve off-origin
|
||||
// — they must fall back to /home, never reach history.pushState.
|
||||
expect(parseAuthFlowStage('?code=abc&redirect=https://evil.example').redirectPath).toBe('/home');
|
||||
expect(parseAuthFlowStage('?register_app=1&redirect=//evil.example').redirectPath).toBe('/home');
|
||||
expect(parseAuthFlowStage('?code=abc&redirect=/\\evil.example').redirectPath).toBe('/home');
|
||||
expect(parseAuthFlowStage('?code=abc&redirect=relative/path').redirectPath).toBe('/home');
|
||||
// A same-origin deep link survives untouched.
|
||||
expect(parseAuthFlowStage('?code=abc&redirect=/events/42').redirectPath).toBe('/events/42');
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { authProvider as createActivityPodsAuthProvider } from '@activitypods/refine-providers';
|
||||
import type { AuthProvider } from '@activitypods/refine-providers';
|
||||
import { resolveActivityPodsAuthConfig } from '../../shared/utils/runtimeConfig';
|
||||
|
||||
// Re-exported so existing callers (AuthGate, the flow-stage tests) keep their
|
||||
// import surface; the implementation lives in the runtime-config module, the
|
||||
// shared source of truth both the auth gate and the data provider resolve
|
||||
// against — one clientId/redirect for the whole app.
|
||||
export { resolveActivityPodsAuthConfig };
|
||||
export type { ResolvedAuthConfig } from '../../shared/utils/runtimeConfig';
|
||||
|
||||
/** Build an ActivityPods auth provider with the Festipod-specific configuration. */
|
||||
export function buildActivityPodsAuthProvider(origin: string): AuthProvider {
|
||||
const cfg = resolveActivityPodsAuthConfig(origin);
|
||||
return createActivityPodsAuthProvider({
|
||||
clientId: cfg.clientId,
|
||||
redirectUri: cfg.redirectUri,
|
||||
appStatusCheckInterval: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* End the ActivityPods session on this device.
|
||||
*
|
||||
* Per the contract, `logout` clears local state — the stored OIDC token — and
|
||||
* the token lives at ONE fixed storage key per page (`One session per page`,
|
||||
* non-guarantees), so clearing it here ends the session for the whole app. The
|
||||
* provider is built from the same resolved config as the gate and the data
|
||||
* provider; the app's own session store (`utils/currentSession`) is cleared by
|
||||
* the caller, before the reload.
|
||||
*/
|
||||
export async function logoutActivityPods(): Promise<void> {
|
||||
await buildActivityPodsAuthProvider(window.location.origin).logout({});
|
||||
}
|
||||
|
||||
/** Which stage of the Solid-OIDC / app-registration flow the current URL represents. */
|
||||
export type AuthFlowStage =
|
||||
| { stage: 'callback'; redirectPath: string }
|
||||
| { stage: 'register_app'; redirectPath: string }
|
||||
| { stage: 'picker'; redirectPath: string };
|
||||
|
||||
/**
|
||||
* A same-origin post-login destination, or `/home`.
|
||||
*
|
||||
* Only a leading-slash path is accepted. An absolute URL, a protocol-relative
|
||||
* `//host` value, its backslash spelling `/\host` (which the URL parser resolves
|
||||
* the same way), or anything not starting with `/` falls back to `/home`.
|
||||
* Validated here, in the one place the URL is parsed, so every consumer — the
|
||||
* callback navigation and the picker's login call — inherits it. Without this an
|
||||
* attacker-chosen `?redirect=` could leave the origin, and `history.pushState`
|
||||
* would throw a SecurityError into the fatal screen.
|
||||
*/
|
||||
export function safeRedirectPath(raw: string | null | undefined): string {
|
||||
if (!raw || !raw.startsWith('/') || raw.startsWith('//') || raw.startsWith('/\\')) {
|
||||
return '/home';
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the current URL search params to decide what AuthGate should do.
|
||||
*
|
||||
* - `?code=...` → the Pod provider redirected back after login; complete the
|
||||
* OAuth exchange and move to app registration.
|
||||
* - `?register_app=1` → back from the Pod's consent screen; ensure the app is
|
||||
* registered before entering the app.
|
||||
* - otherwise → show the Pod-provider picker.
|
||||
*
|
||||
* `redirect` is validated (`safeRedirectPath`) and carried on every stage,
|
||||
* defaulting to `/home`.
|
||||
*/
|
||||
export function parseAuthFlowStage(search: string): AuthFlowStage {
|
||||
const params = new URLSearchParams(search);
|
||||
const redirectPath = safeRedirectPath(params.get('redirect'));
|
||||
if (params.has('code')) return { stage: 'callback', redirectPath };
|
||||
if (params.has('register_app')) return { stage: 'register_app', redirectPath };
|
||||
return { stage: 'picker', redirectPath };
|
||||
}
|
||||
+27
-5
@@ -24,16 +24,38 @@
|
||||
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { setRuntimeConfig } from "../shared/utils/runtimeConfig";
|
||||
|
||||
/** Fetch the runtime shared-wallet config and set the global, unless it is already there. */
|
||||
/** Runtime config shape served by `/festipod-config.json`. */
|
||||
interface FestipodConfig {
|
||||
sharedWalletPassword?: string;
|
||||
autoSeed?: string;
|
||||
backendUrl?: string;
|
||||
backendClientId?: string;
|
||||
podProviderUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the runtime config and make it available to the app.
|
||||
*
|
||||
* The shared-wallet globals may already be inlined by `build.ts`'s `define`; the
|
||||
* runtime endpoint is still fetched for the ActivityPods backend values, which
|
||||
* are env-driven and not baked into the bundle.
|
||||
*/
|
||||
async function loadRuntimeConfig(): Promise<void> {
|
||||
// Already inlined by `build.ts`'s `define` → nothing to fetch. Bracket access,
|
||||
// so that same `define` (which rewrites the dotted global) leaves this read alone.
|
||||
if ((globalThis as Record<string, unknown>)["__FESTIPOD_SHARED_WALLET_PASSWORD__"] != null) return;
|
||||
try {
|
||||
const res = await fetch("/festipod-config.json");
|
||||
if (!res.ok) return;
|
||||
const cfg = (await res.json()) as { sharedWalletPassword?: string; autoSeed?: string };
|
||||
const cfg = (await res.json()) as FestipodConfig;
|
||||
|
||||
// Backend coupling: the browser cannot read process.env, so the server exposes
|
||||
// the values the ActivityPods auth flow needs.
|
||||
setRuntimeConfig({
|
||||
backendUrl: cfg.backendUrl,
|
||||
backendClientId: cfg.backendClientId,
|
||||
podProviderUrl: cfg.podProviderUrl,
|
||||
});
|
||||
|
||||
// Bracket access so build.ts's `define` (which matches the dotted global)
|
||||
// never rewrites this assignment. Only set when the env actually carries one.
|
||||
const g = globalThis as Record<string, unknown>;
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { parsePath, routeToPath, eventPath, userPath, type Route } from './router';
|
||||
|
||||
// Ids on the ActivityPods backend are absolute IRIs. The router must carry them
|
||||
// through the URL intact: encode on the way out, decode on the way in. A raw IRI
|
||||
// in a path would be read as route structure (the `/` and `:` split it, and the
|
||||
// `http:` segment falls through to the welcome page).
|
||||
|
||||
const IRI = 'http://localhost:3000/alice/data/abc';
|
||||
|
||||
test('an absolute-IRI event id survives routeToPath → parsePath intact', () => {
|
||||
const route: Route = { page: 'event-detail', eventId: IRI };
|
||||
const path = routeToPath(route);
|
||||
expect(path).toBe(`/events/${encodeURIComponent(IRI)}`);
|
||||
expect(parsePath(path)).toEqual({ page: 'event-detail', eventId: IRI });
|
||||
});
|
||||
|
||||
test('an absolute-IRI event id survives every sub-route intact', () => {
|
||||
const cases: Array<[Route, Route]> = [
|
||||
[{ page: 'update-event', eventId: IRI }, { page: 'update-event', eventId: IRI }],
|
||||
[{ page: 'invite', eventId: IRI }, { page: 'invite', eventId: IRI }],
|
||||
[{ page: 'participants', eventId: IRI }, { page: 'participants', eventId: IRI }],
|
||||
[{ page: 'meeting-points', eventId: IRI }, { page: 'meeting-points', eventId: IRI }],
|
||||
];
|
||||
for (const [route, expected] of cases) {
|
||||
expect(parsePath(routeToPath(route))).toEqual(expected);
|
||||
}
|
||||
});
|
||||
|
||||
test('an absolute-IRI user id survives routeToPath → parsePath intact', () => {
|
||||
const route: Route = { page: 'user-profile', userId: IRI };
|
||||
expect(parsePath(routeToPath(route))).toEqual({ page: 'user-profile', userId: IRI });
|
||||
});
|
||||
|
||||
// The id ITSELF may end in a segment that names a sub-route; encoding is what
|
||||
// keeps it from being read as one.
|
||||
test('an encoded id whose tail looks like a sub-route is not mistaken for one', () => {
|
||||
const tricky = 'http://localhost:3000/alice/data/edit';
|
||||
expect(parsePath(routeToPath({ page: 'event-detail', eventId: tricky }))).toEqual({
|
||||
page: 'event-detail',
|
||||
eventId: tricky,
|
||||
});
|
||||
expect(parsePath(eventPath(tricky, 'participants'))).toEqual({
|
||||
page: 'participants',
|
||||
eventId: tricky,
|
||||
});
|
||||
});
|
||||
|
||||
test('eventPath and userPath are the encoded path builders', () => {
|
||||
expect(eventPath(IRI)).toBe(`/events/${encodeURIComponent(IRI)}`);
|
||||
expect(eventPath(IRI, 'edit')).toBe(`/events/${encodeURIComponent(IRI)}/edit`);
|
||||
expect(userPath(IRI)).toBe(`/users/${encodeURIComponent(IRI)}`);
|
||||
});
|
||||
|
||||
// Defensive: a raw (unencoded) IRI still parses rather than falling through —
|
||||
// the id is taken as the whole remainder and the sub-route from the tail.
|
||||
test('a raw absolute-IRI path still parses as the whole remainder', () => {
|
||||
expect(parsePath('/events/http://localhost:3000/alice/data/abc')).toEqual({
|
||||
page: 'event-detail',
|
||||
eventId: IRI,
|
||||
});
|
||||
expect(parsePath('/events/http://localhost:3000/alice/data/abc/participants')).toEqual({
|
||||
page: 'participants',
|
||||
eventId: IRI,
|
||||
});
|
||||
});
|
||||
|
||||
test('the fixed routes are unchanged', () => {
|
||||
expect(parsePath('/')).toEqual({ page: 'welcome' });
|
||||
expect(parsePath('/home')).toEqual({ page: 'home' });
|
||||
expect(parsePath('/events')).toEqual({ page: 'events' });
|
||||
expect(parsePath('/events/new')).toEqual({ page: 'create-event' });
|
||||
expect(parsePath('/profile/edit')).toEqual({ page: 'edit-profile' });
|
||||
expect(parsePath('/unknown')).toEqual({ page: 'welcome' });
|
||||
});
|
||||
+53
-14
@@ -20,7 +20,8 @@ type Route =
|
||||
| { page: 'share-profile' }
|
||||
| { page: 'connect' }
|
||||
| { page: 'user-profile'; userId: string }
|
||||
| { page: 'settings' };
|
||||
| { page: 'settings' }
|
||||
| { page: 'login' };
|
||||
|
||||
export type { Route };
|
||||
|
||||
@@ -33,13 +34,28 @@ export interface RouteParams {
|
||||
// Path parsing & generation
|
||||
// ============================================================================
|
||||
|
||||
function parsePath(pathname: string): Route {
|
||||
/**
|
||||
* Decode one path segment. Ids on the ActivityPods backend are absolute IRIs, so
|
||||
* they are percent-encoded by `routeToPath`; a malformed escape (a raw IRI that
|
||||
* was never encoded) is returned verbatim rather than throwing — the captured
|
||||
* text IS the id in that case.
|
||||
*/
|
||||
function decodeSegment(segment: string): string {
|
||||
try {
|
||||
return decodeURIComponent(segment);
|
||||
} catch {
|
||||
return segment;
|
||||
}
|
||||
}
|
||||
|
||||
export function parsePath(pathname: string): Route {
|
||||
const path = pathname.replace(/\/+$/, '') || '/';
|
||||
|
||||
if (path === '/' || path === '') return { page: 'welcome' };
|
||||
if (path === '/home') return { page: 'home' };
|
||||
if (path === '/events') return { page: 'events' };
|
||||
if (path === '/events/new') return { page: 'create-event' };
|
||||
if (path === '/login') return { page: 'login' };
|
||||
if (path === '/settings') return { page: 'settings' };
|
||||
if (path === '/profile') return { page: 'profile' };
|
||||
if (path === '/profile/edit') return { page: 'edit-profile' };
|
||||
@@ -47,10 +63,14 @@ function parsePath(pathname: string): Route {
|
||||
if (path === '/profile/share') return { page: 'share-profile' };
|
||||
if (path === '/profile/connect') return { page: 'connect' };
|
||||
|
||||
// /events/:id/...
|
||||
const eventMatch = path.match(/^\/events\/([^/]+)(?:\/(.+))?$/);
|
||||
// /events/:id/... — the id is an absolute IRI, encoded by `routeToPath` so it
|
||||
// holds no `/`; the sub-route is matched from the tail, and a raw (unencoded)
|
||||
// IRI still parses as the whole remainder. An ENCODED id may itself contain a
|
||||
// `/edit` segment without being read as a sub-route, which is why encoding is
|
||||
// mandatory on every navigation.
|
||||
const eventMatch = path.match(/^\/events\/(.+?)(?:\/(edit|invite|participants|meeting-points))?$/);
|
||||
if (eventMatch) {
|
||||
const eventId = eventMatch[1]!;
|
||||
const eventId = decodeSegment(eventMatch[1]!);
|
||||
const sub = eventMatch[2];
|
||||
if (!sub) return { page: 'event-detail', eventId };
|
||||
if (sub === 'edit') return { page: 'update-event', eventId };
|
||||
@@ -59,10 +79,10 @@ function parsePath(pathname: string): Route {
|
||||
if (sub === 'meeting-points') return { page: 'meeting-points', eventId };
|
||||
}
|
||||
|
||||
// /users/:id
|
||||
const userMatch = path.match(/^\/users\/([^/]+)$/);
|
||||
// /users/:id — an absolute actor URI, encoded for the same reason.
|
||||
const userMatch = path.match(/^\/users\/(.+)$/);
|
||||
if (userMatch) {
|
||||
return { page: 'user-profile', userId: userMatch[1]! };
|
||||
return { page: 'user-profile', userId: decodeSegment(userMatch[1]!) };
|
||||
}
|
||||
|
||||
return { page: 'welcome' };
|
||||
@@ -74,21 +94,40 @@ export function routeToPath(route: Route): string {
|
||||
case 'home': return '/home';
|
||||
case 'events': return '/events';
|
||||
case 'create-event': return '/events/new';
|
||||
case 'event-detail': return `/events/${route.eventId}`;
|
||||
case 'update-event': return `/events/${route.eventId}/edit`;
|
||||
case 'invite': return `/events/${route.eventId}/invite`;
|
||||
case 'participants': return `/events/${route.eventId}/participants`;
|
||||
case 'meeting-points': return `/events/${route.eventId}/meeting-points`;
|
||||
case 'login': return '/login';
|
||||
// Ids are absolute IRIs: they MUST be percent-encoded into a single path
|
||||
// segment, or the `/` and `:` inside them are read as route structure and
|
||||
// navigation falls through to the welcome page. `parsePath` decodes them.
|
||||
case 'event-detail': return `/events/${encodeURIComponent(route.eventId)}`;
|
||||
case 'update-event': return `/events/${encodeURIComponent(route.eventId)}/edit`;
|
||||
case 'invite': return `/events/${encodeURIComponent(route.eventId)}/invite`;
|
||||
case 'participants': return `/events/${encodeURIComponent(route.eventId)}/participants`;
|
||||
case 'meeting-points': return `/events/${encodeURIComponent(route.eventId)}/meeting-points`;
|
||||
case 'profile': return '/profile';
|
||||
case 'edit-profile': return '/profile/edit';
|
||||
case 'friends': return '/profile/friends';
|
||||
case 'share-profile': return '/profile/share';
|
||||
case 'connect': return '/profile/connect';
|
||||
case 'user-profile': return `/users/${route.userId}`;
|
||||
case 'user-profile': return `/users/${encodeURIComponent(route.userId)}`;
|
||||
case 'settings': return '/settings';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a path to an event sub-route, encoding the id. Screens navigate by path
|
||||
* rather than by `Route`, so this is the one place that keeps a raw IRI out of
|
||||
* the URL structure.
|
||||
*/
|
||||
export function eventPath(eventId: string, sub?: 'edit' | 'invite' | 'participants' | 'meeting-points'): string {
|
||||
const base = `/events/${encodeURIComponent(eventId)}`;
|
||||
return sub ? `${base}/${sub}` : base;
|
||||
}
|
||||
|
||||
/** Build a path to a user profile, encoding the actor URI. */
|
||||
export function userPath(userId: string): string {
|
||||
return `/users/${encodeURIComponent(userId)}`;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Router context
|
||||
// ============================================================================
|
||||
|
||||
+13
-4
@@ -1,7 +1,7 @@
|
||||
import { serve } from "bun";
|
||||
import index from "./index.html";
|
||||
|
||||
const port = process.env.PORT ? parseInt(process.env.PORT) : 3000;
|
||||
const port = process.env.PORT ? parseInt(process.env.PORT) : 4000;
|
||||
|
||||
// Strict base64 check (not a mere `Buffer.from` attempt, which silently drops invalid
|
||||
// characters instead of failing): reject anything that is not a well-formed base64 body
|
||||
@@ -63,13 +63,22 @@ const server = serve({
|
||||
// (both serve from src/, so they miss build.ts's compile-time `define`). The app
|
||||
// entry (frontend.tsx) fetches this before it loads the app tree, so
|
||||
// `sharedWallet.ts` sees the password. Empty env → '' → no shared wallet.
|
||||
"/festipod-config.json": () =>
|
||||
Response.json({
|
||||
"/festipod-config.json": () => {
|
||||
const backendUrl = process.env.VITE_BACKEND_URL?.trim() ?? "";
|
||||
const backendClientId = process.env.VITE_BACKEND_CLIENT_ID?.trim() ?? "";
|
||||
return Response.json({
|
||||
sharedWalletPassword: process.env.FESTIPOD_SHARED_WALLET_PASSWORD ?? "",
|
||||
// Auto-seed gate (OFF by default): only set when the env var is present, so
|
||||
// the front seeds an empty wallet only on explicit opt-in (see autoSeed.ts).
|
||||
autoSeed: process.env.FESTIPOD_AUTO_SEED ?? "",
|
||||
}),
|
||||
// ActivityPods backend coupling: the frontend needs the backend URL and the
|
||||
// Solid-OIDC client_id (defaults to the app description document served by
|
||||
// the backend). The Pod provider URL is optional; empty → user picks one.
|
||||
backendUrl,
|
||||
backendClientId,
|
||||
podProviderUrl: process.env.VITE_POD_PROVIDER_URL?.trim() ?? "",
|
||||
});
|
||||
},
|
||||
|
||||
// The shared wallet file — the `fileUrl` the app hands the SDK, when configured.
|
||||
//
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# language: fr
|
||||
@AUTH @priority-1
|
||||
@AUTH @priority-1 @suspendu
|
||||
Fonctionnalité: Connexion NextGraph et chargement des données
|
||||
En tant qu'utilisateur
|
||||
Je peux me connecter à mon portefeuille NextGraph
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# language: fr
|
||||
@EVENT @priority-1
|
||||
@EVENT @priority-1 @suspendu
|
||||
Fonctionnalité: Cycle de vie d'un événement
|
||||
En tant qu'utilisateur connecté
|
||||
Je peux créer, consulter, modifier et participer à des événements
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
# ANNUAIRE Festipod — un document public dont l'app connaît le lien, alimenté par
|
||||
# dépôt et matérialisé par son curateur. À ce moment-là ce scénario est réécrit
|
||||
# sur l'annuaire et redevient actif.
|
||||
@EVENT @priority-1 @wip
|
||||
@EVENT @priority-1 @wip @suspendu
|
||||
Fonctionnalité: Découverte publique via l'index global
|
||||
En tant qu'utilisateur
|
||||
Je veux découvrir les événements publics des autres comptes sans être connecté
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# language: fr
|
||||
@EVENT @priority-1 @e2e-activitypods
|
||||
Fonctionnalité: Smoke e2e de la pile ActivityPods locale
|
||||
En tant que développeur validant le branchement ActivityPods
|
||||
Je pilote la pile locale réelle (pod provider + backend Festipod + app servie)
|
||||
Afin de prouver que les flux du modèle passent réellement par le Pod.
|
||||
|
||||
# PÉRIMÈTRE. Cette couche est DISTINCTE des suites `@data`/`@e2e`/`@multibrowser`
|
||||
# suspendues avec le workstream NextGraph : elle ne signe aucun wallet et ne monte
|
||||
# aucun broker. Elle n'existe que si la pile locale tourne — son mode d'emploi
|
||||
# reproductible est le concept `data-layer`,
|
||||
# `cookbook_local-activitypods-platform.md` (pod provider :3000, backend Festipod
|
||||
# :3003, redis remappé :6390). Le chargement de l'app reprend le motif `@e2e` du
|
||||
# repo (serveur réel + Playwright), pas le wallet partagé.
|
||||
|
||||
Scénario: La pile locale est joignable et le backend sert son application
|
||||
Alors le pod provider local répond sur son endpoint OpenID
|
||||
Et le backend Festipod sert la description de son application
|
||||
|
||||
@browser
|
||||
Scénario: L'application démarre en variante ActivityPods et propose la connexion au Pod
|
||||
Quand l'application Festipod est chargée depuis la pile locale
|
||||
Alors l'entrée de connexion au Pod est proposée
|
||||
Et aucune erreur runtime n'a été émise au chargement
|
||||
|
||||
Scénario: Un événement et un point de rencontre rattaché par as:context sont créés sur le Pod
|
||||
Étant donné une session Pod locale pour "alice" avec le mot de passe "Test1test"
|
||||
Quand je crée un événement "Concert smoke" et un point de rencontre "Rdv entrée" rattaché par as:context
|
||||
Alors la lecture du point de rencontre expose son lien as:context vers l'événement
|
||||
Et l'événement et le point de rencontre partagent le conteneur as:Event
|
||||
|
||||
# L'app s'installe sur le Pod (consentement de l'agent d'autorisation, ou pour un
|
||||
# scénario sans navigateur l'endpoint d'enregistrement — voir l'étape
|
||||
# « installée sur le Pod »). Une fois installée, l'activité `as:Join` postée sur
|
||||
# l'outbox de l'utilisateur EST délivrée au backend, qui tient la collection
|
||||
# `apods:attendees` du point de rencontre. Le point de rencontre est créé dans le
|
||||
# conteneur enregistré (`as/event`) ; sa collection `apods:attendees` est attachée
|
||||
# par le backend lui-même, à la création — le scénario la lit, il ne la crée pas.
|
||||
Scénario: Rejoindre puis quitter un point de rencontre met à jour la collection apods:attendees
|
||||
Étant donné une session Pod locale pour "alice" avec le mot de passe "Test1test"
|
||||
Et l'application Festipod est installée sur le Pod de la session
|
||||
Et un point de rencontre "Rdv entrée" rattaché à un événement, avec sa collection apods:attendees
|
||||
Quand la session rejoint le point de rencontre
|
||||
Alors la collection apods:attendees contient la session
|
||||
Quand la session quitte le point de rencontre
|
||||
Alors la collection apods:attendees ne contient plus la session
|
||||
|
||||
# Dépend du scénario précédent pour la mise en place, mais lit la collection
|
||||
# elle-même : le compteur est le total de la collection (jamais les profils
|
||||
# résolus) et une référence non résolue reste comptée et rendue anonyme.
|
||||
Scénario: Le compteur et la liste des participants tiennent une référence non résolue
|
||||
Étant donné une session Pod locale pour "alice" avec le mot de passe "Test1test"
|
||||
Et l'application Festipod est installée sur le Pod de la session
|
||||
Et un point de rencontre "Rdv entrée" rattaché à un événement, avec sa collection apods:attendees
|
||||
Quand la session rejoint le point de rencontre
|
||||
Alors le compteur du point de rencontre compte cette inscription
|
||||
Et la liste des participants rend ce participant comme anonyme
|
||||
@@ -1,5 +1,5 @@
|
||||
# language: fr
|
||||
@EVENT @priority-1 @multibrowser @shared-wallet
|
||||
@EVENT @priority-1 @multibrowser @shared-wallet @suspendu
|
||||
Fonctionnalité: Validation e2e multi-navigateurs des nouvelles features (T02.f)
|
||||
En tant que développeur validant les fonctionnalités T02
|
||||
Je pilote DEUX navigateurs isolés (contextes Playwright distincts, sessions
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# language: fr
|
||||
@EVENT @priority-1
|
||||
@EVENT @priority-1 @suspendu
|
||||
Fonctionnalité: Inscription réelle au point de rencontre via inbox (T02.c)
|
||||
En tant qu'utilisateur qui s'inscrit à un point de rencontre
|
||||
Mon inscription est persistée, l'hôte reçoit une notification par son inbox,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# language: fr
|
||||
# SUSPENDU (@wip) — comportement Festipod bien réel, mais inexprimable aujourd'hui : il faut DEUX contextes navigateur réellement séparés, chacun se connectant pour lui-même via `ensureIdentity()`, ce que le support multi-navigateur n'offre pas encore.
|
||||
@EVENT @priority-1 @data @wip
|
||||
@EVENT @priority-1 @data @wip @suspendu
|
||||
Fonctionnalité: Isolation entre deux identités sur le wallet partagé
|
||||
En tant qu'utilisateur qui se connecte pour lui-même, sur le MÊME wallet
|
||||
physique partagé qu'un autre utilisateur,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# language: fr
|
||||
@EVENT @priority-1 @data
|
||||
@EVENT @priority-1 @data @suspendu
|
||||
Fonctionnalité: Reconnexion à froid SANS état local NextGraph (durabilité broker réelle)
|
||||
En tant qu'utilisateur qui crée un événement puis se reconnecte sous la MÊME
|
||||
identité depuis un appareil/onglet SANS aucune donnée NextGraph locale,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# language: fr
|
||||
@EVENT @priority-1 @data
|
||||
@EVENT @priority-1 @data @suspendu
|
||||
Fonctionnalité: Reconnexion d'une même identité sur le wallet persistant
|
||||
En tant qu'utilisateur qui, sur le MÊME wallet physique, ouvre une PAGE FRAÎCHE
|
||||
(nouveau login broker, session fraîche) sous le MÊME identifiant qu'avant,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# language: fr
|
||||
@EVENT @priority-1
|
||||
@EVENT @priority-1 @suspendu
|
||||
Fonctionnalité: Persistance d'un événement à la reconnexion (app réelle, @e2e)
|
||||
En tant qu'utilisateur qui, dans la VRAIE app, crée un événement puis FERME
|
||||
et ROUVRE l'app sous la MÊME identité (même wallet, session verifier fraîche),
|
||||
|
||||
@@ -34,7 +34,7 @@ Fonctionnalité: US-13 Relayer/Modifier/Supprimer un événement
|
||||
# le créateur, qui n'est PAS obligé de participer. À la création, aucune
|
||||
# participation n'est écrite et le compteur démarre à 0. Le créateur voit « J'y
|
||||
# serai » et peut rejoindre/quitter son propre événement comme tout le monde.
|
||||
@data
|
||||
@data @suspendu
|
||||
Scénario: Le créateur ne participe pas automatiquement à son événement
|
||||
Étant donné que le créateur relaie l'événement "Signalé par le créateur"
|
||||
Alors le créateur n'est pas participant de l'événement "Signalé par le créateur"
|
||||
|
||||
@@ -37,7 +37,7 @@ Fonctionnalité: US-7 M'inscrire/me désinscrire à un événement
|
||||
# scénario @multibrowser réactif (e2e-multibrowser.feature « Un participant apparaît
|
||||
# réactivement… »), avec un vrai propriétaire (A) et un vrai inscrit (B).
|
||||
|
||||
@data
|
||||
@data @suspendu
|
||||
Scénario: S'inscrire à un événement
|
||||
Étant donné un événement "Formation CNV" existe
|
||||
Et l'utilisateur n'est pas inscrit à l'événement "Formation CNV"
|
||||
@@ -45,7 +45,7 @@ Fonctionnalité: US-7 M'inscrire/me désinscrire à un événement
|
||||
Alors l'utilisateur est participant de l'événement "Formation CNV"
|
||||
Et l'utilisateur apparaît dans la liste des participants de l'événement "Formation CNV"
|
||||
|
||||
@data
|
||||
@data @suspendu
|
||||
Scénario: Se désinscrire d'un événement
|
||||
Étant donné un événement "Résidence Reconnexion" existe
|
||||
Et l'utilisateur est inscrit à l'événement "Résidence Reconnexion"
|
||||
@@ -53,14 +53,14 @@ Fonctionnalité: US-7 M'inscrire/me désinscrire à un événement
|
||||
Alors l'utilisateur n'est plus participant de l'événement "Résidence Reconnexion"
|
||||
Et l'utilisateur n'apparaît plus dans la liste des participants de l'événement "Résidence Reconnexion"
|
||||
|
||||
@data
|
||||
@data @suspendu
|
||||
Scénario: L'inscription est idempotente
|
||||
Étant donné un événement "Résidence Reconnexion" existe
|
||||
Et l'utilisateur est inscrit à l'événement "Résidence Reconnexion"
|
||||
Quand l'utilisateur essaie de s'inscrire une seconde fois à l'événement "Résidence Reconnexion"
|
||||
Alors l'inscription est idempotente pour l'événement "Résidence Reconnexion"
|
||||
|
||||
@data
|
||||
@data @suspendu
|
||||
Scénario: Se désinscrire d'un événement auquel on n'est pas inscrit
|
||||
Étant donné un événement "Formation CNV" existe
|
||||
Et l'utilisateur n'est pas inscrit à l'événement "Formation CNV"
|
||||
@@ -80,7 +80,7 @@ Fonctionnalité: US-7 M'inscrire/me désinscrire à un événement
|
||||
# non-borné au scénario). La convergence 1→2 réactive absolue reste prouvée par le
|
||||
# scénario @multibrowser réactif (propriétaire A + inscrit B, e2e-multibrowser).
|
||||
# Voir data-layer/knowledge_context-internals § participantCount + le caveat id-form.
|
||||
@data
|
||||
@data @suspendu
|
||||
Scénario: L'inscription fait converger le compteur dérivé du propriétaire
|
||||
Étant donné un événement "Formation CNV" existe
|
||||
Et l'utilisateur n'est pas inscrit à l'événement "Formation CNV"
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState, useMemo } from 'react';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { Header, Text, Input, Button, Placeholder, showToast } from '../../../shared/components/sketchy';
|
||||
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
|
||||
import { useNavigate } from '../../../app/router';
|
||||
import { eventPath, useNavigate } from '../../../app/router';
|
||||
|
||||
const importableEvents = [
|
||||
{
|
||||
@@ -108,7 +108,7 @@ export function CreateEventScreen() {
|
||||
return;
|
||||
}
|
||||
showToast('Événement relayé', 'success');
|
||||
navigate(`/events/${newEvent.id}`);
|
||||
navigate(eventPath(newEvent.id));
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -239,7 +239,7 @@ export function CreateEventScreen() {
|
||||
<Button
|
||||
variant="primary"
|
||||
style={{ width: '100%', padding: 10, fontSize: 13 }}
|
||||
onClick={() => navigate(`/events/${ev.id}`)}
|
||||
onClick={() => navigate(eventPath(ev.id))}
|
||||
>
|
||||
Voir cet événement
|
||||
</Button>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { Button, Avatar, EventCover, EventMeetingPoints, showToast, Text, type MeetingPointData } from '../../../shared/components/sketchy';
|
||||
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
|
||||
import { useNavigate, useParams } from '../../../app/router';
|
||||
import { eventPath, useNavigate, useParams, userPath } from '../../../app/router';
|
||||
|
||||
export function EventDetailScreen() {
|
||||
const navigate = useNavigate();
|
||||
@@ -10,15 +10,14 @@ export function EventDetailScreen() {
|
||||
getEvent,
|
||||
currentUser,
|
||||
isParticipating,
|
||||
joinEvent,
|
||||
leaveEvent,
|
||||
joinMeetingPoint,
|
||||
leaveMeetingPoint,
|
||||
getEventParticipants,
|
||||
getEventMeetingPoints,
|
||||
getEventOwnership,
|
||||
} = useFestipodData();
|
||||
|
||||
const event = eventId ? getEvent(eventId) : undefined;
|
||||
const joined = eventId ? isParticipating(eventId) : false;
|
||||
const participants = eventId ? getEventParticipants(eventId) : [];
|
||||
const meetingPointsRaw = eventId ? getEventMeetingPoints(eventId) : [];
|
||||
|
||||
@@ -30,6 +29,30 @@ export function EventDetailScreen() {
|
||||
lieu: mp.location,
|
||||
}));
|
||||
|
||||
// SIGNING UP IS PER MEETING POINT — the product model (on s'inscrit à un
|
||||
// point de rencontre, pas à un événement). There is no event-level join on
|
||||
// this screen anymore: "joined" means a member of at least one of this
|
||||
// event's meeting points, and it gates the Invite affordance the way the
|
||||
// old event-level membership did.
|
||||
const joinedMeetingPointIds = new Set(
|
||||
meetingPointsRaw.filter(mp => isParticipating(mp.id)).map(mp => String(mp.id)),
|
||||
);
|
||||
const joined = joinedMeetingPointIds.size > 0;
|
||||
|
||||
const handleToggleMeetingPoint = (id: string, _title: string, willJoin: boolean) => {
|
||||
const failed = (message: string) => (err: unknown) => {
|
||||
console.error('[EventDetail] meeting point participation failed:', err);
|
||||
showToast(message, 'error');
|
||||
};
|
||||
if (willJoin) {
|
||||
void Promise.resolve(joinMeetingPoint(id))
|
||||
.catch(failed("L'inscription au point de rencontre n'a pas pu être enregistrée"));
|
||||
} else {
|
||||
void Promise.resolve(leaveMeetingPoint(id))
|
||||
.catch(failed("La désinscription du point de rencontre n'a pas pu être enregistrée"));
|
||||
}
|
||||
};
|
||||
|
||||
// EDITING IS OWNING. Only a document's owner writes to it, and nothing delegates
|
||||
// a write, so "may I edit this event" is answered entirely by whether this
|
||||
// session owns the event's document — no permission call, no extra read.
|
||||
@@ -45,33 +68,14 @@ export function EventDetailScreen() {
|
||||
const canEdit = ownership === 'mine';
|
||||
// Preview list shows the OTHER participants (deliberate — the total is in the
|
||||
// header count; the full list at "Voir tous les participants" shows everyone).
|
||||
// Compare on the PROFILE id: `currentUser.id` is the resolved profile NURI, the
|
||||
// same space as `p.id` — whereas `currentUserId` is the `urn:festipod:user:`
|
||||
// principal, which never equals a profile id in connected mode (so the old
|
||||
// `p.id !== currentUserId` failed to drop self, leaking it in as "1 unknown").
|
||||
// On this backend `currentUser.id` and `p.id` are BOTH actor URIs (the id the
|
||||
// attendees collection keys memberships by), so comparing them drops self.
|
||||
const knownParticipants = participants.filter(p => p.id !== currentUser?.id);
|
||||
|
||||
const handleToggleJoin = () => {
|
||||
if (!eventId) return;
|
||||
// THE CONFIRMATION FOLLOWS THE WRITE. It used to be shown on the spot, before
|
||||
// the call had settled, so a sign-up that wrote nothing still read as
|
||||
// « Tu participes ». The screen's own list flips immediately anyway (the data
|
||||
// layer's optimistic overlay), so nothing is lost by waiting for the truth.
|
||||
const confirmed = (message: string, tone: 'success' | 'info') => () => showToast(message, tone);
|
||||
const failed = (message: string) => (err: unknown) => {
|
||||
console.error('[EventDetail] participation write failed:', err);
|
||||
showToast(message, 'error');
|
||||
};
|
||||
if (joined) {
|
||||
void Promise.resolve(leaveEvent(eventId))
|
||||
.then(confirmed('Participation annulée', 'info'))
|
||||
.catch(failed("La désinscription n'a pas pu être enregistrée"));
|
||||
} else {
|
||||
void Promise.resolve(joinEvent(eventId))
|
||||
.then(confirmed('Tu participes à cet événement', 'success'))
|
||||
.catch(failed("L'inscription n'a pas pu être enregistrée"));
|
||||
}
|
||||
};
|
||||
// The rest of the total could not be resolved to a profile — a non-contact
|
||||
// sees only anonymous attendees. The total still counts them, so the
|
||||
// difference is rendered as anonymous rows (mirrors ParticipantsListScreen),
|
||||
// never dropped: the count and the rows must agree.
|
||||
const unknownCount = Math.max(0, (event?.participantCount ?? 0) - participants.length);
|
||||
|
||||
if (!event) {
|
||||
return (
|
||||
@@ -100,7 +104,7 @@ export function EventDetailScreen() {
|
||||
</div>
|
||||
{canEdit && (
|
||||
<span
|
||||
onClick={() => navigate(`/events/${eventId}/edit`)}
|
||||
onClick={() => navigate(eventPath(event.id, 'edit'))}
|
||||
title="Modifier l'événement"
|
||||
style={{ cursor: 'pointer', fontSize: 18, color: '#888' }}
|
||||
>✎</span>
|
||||
@@ -150,30 +154,26 @@ export function EventDetailScreen() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ margin: '4px 16px 16px', display: 'flex', gap: 8 }}>
|
||||
<Button
|
||||
variant={joined ? 'green' : 'primary'}
|
||||
onClick={handleToggleJoin}
|
||||
style={{ flex: 1, padding: '12px 0' }}
|
||||
>
|
||||
{joined ? '✓ Je participe' : "J'y serai"}
|
||||
</Button>
|
||||
{joined && (
|
||||
{/* SIGNING UP IS PER MEETING POINT: no event-level join button here. The
|
||||
join affordances live on the meeting points below; Invite follows
|
||||
membership, the way the old event-level "Je participe" did. */}
|
||||
{joined && (
|
||||
<div style={{ margin: '4px 16px 16px', display: 'flex', gap: 8 }}>
|
||||
<Button
|
||||
onClick={() => navigate(`/events/${eventId}/invite`)}
|
||||
onClick={() => navigate(eventPath(event.id, 'invite'))}
|
||||
style={{ flex: 1, padding: '12px 0' }}
|
||||
>
|
||||
Inviter
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{meetingPoints.length > 0 && (
|
||||
<div style={{ padding: '0 16px 8px' }}>
|
||||
<EventMeetingPoints
|
||||
points={meetingPoints}
|
||||
joinedIds={new Set()}
|
||||
onToggle={() => {}}
|
||||
joinedIds={joinedMeetingPointIds}
|
||||
onToggle={handleToggleMeetingPoint}
|
||||
expanded
|
||||
/>
|
||||
</div>
|
||||
@@ -181,7 +181,7 @@ export function EventDetailScreen() {
|
||||
|
||||
<div style={{ padding: '0 16px 8px' }}>
|
||||
<button
|
||||
onClick={() => navigate(`/events/${eventId}/meeting-points`)}
|
||||
onClick={() => navigate(eventPath(event.id, 'meeting-points'))}
|
||||
style={{ width: '100%', padding: 12, border: '2px dashed #ddd', borderRadius: 12, background: 'none', fontSize: 13, fontWeight: 600, color: '#999', cursor: 'pointer', marginTop: 4, fontFamily: 'var(--font-app)' }}
|
||||
>
|
||||
+ Proposer un point de rencontre
|
||||
@@ -195,7 +195,7 @@ export function EventDetailScreen() {
|
||||
{knownParticipants.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
onClick={() => navigate(`/users/${p.id}`)}
|
||||
onClick={() => navigate(userPath(p.id))}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 0', borderBottom: '1px solid #f5f5f5', cursor: 'pointer' }}
|
||||
>
|
||||
<Avatar initials={p.initials} size={38} color="#2B6CB0" />
|
||||
@@ -207,10 +207,21 @@ export function EventDetailScreen() {
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{knownParticipants.length < event.participantCount && (
|
||||
{Array.from({ length: unknownCount }, (_, i) => (
|
||||
<div
|
||||
key={`unknown-${i}`}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 0', borderBottom: '1px solid #f5f5f5' }}
|
||||
>
|
||||
<Avatar initials="?" size={38} color="#ccc" />
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 14, color: '#999' }}>Participant inconnu</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{unknownCount > 0 && (
|
||||
<div
|
||||
style={{ marginTop: 12, padding: 12, background: '#f9f9f9', borderRadius: 12, textAlign: 'center', cursor: 'pointer' }}
|
||||
onClick={() => navigate(`/events/${eventId}/participants`)}
|
||||
onClick={() => navigate(eventPath(event.id, 'participants'))}
|
||||
>
|
||||
<span style={{ fontSize: 12, color: '#999' }}>Voir tous les participants →</span>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { Header, Input, Card, Badge, BottomNav, AvatarStack, getEventPhotoUrl, Text } from '../../../shared/components/sketchy';
|
||||
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
|
||||
import { useNavigate } from '../../../app/router';
|
||||
import { eventPath, useNavigate } from '../../../app/router';
|
||||
|
||||
const PEOPLE = [
|
||||
{ name: 'Marie Leroy', color: '#E8590C' },
|
||||
@@ -77,7 +77,7 @@ export function EventsScreen() {
|
||||
</Text>
|
||||
)}
|
||||
{events.map(event => (
|
||||
<EventCard key={event.id} event={event} onClick={() => navigate(`/events/${event.id}`)} />
|
||||
<EventCard key={event.id} event={event} onClick={() => navigate(eventPath(event.id))} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState } from 'react';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { Header, Input, Text, Avatar, Checkbox, Button, showToast } from '../../../shared/components/sketchy';
|
||||
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
|
||||
import { useNavigate, useParams } from '../../../app/router';
|
||||
import { eventPath, useNavigate, useParams } from '../../../app/router';
|
||||
|
||||
export function InviteScreen() {
|
||||
const navigate = useNavigate();
|
||||
@@ -24,7 +24,7 @@ export function InviteScreen() {
|
||||
|
||||
const send = () => {
|
||||
showToast(`${selected.size} invitation${selected.size > 1 ? 's' : ''} envoyée${selected.size > 1 ? 's' : ''}`, 'success');
|
||||
navigate(`/events/${eventId}`);
|
||||
navigate(eventPath(eventId ?? ''));
|
||||
};
|
||||
|
||||
if (step === 'message') {
|
||||
@@ -122,7 +122,7 @@ export function InviteScreen() {
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<Header
|
||||
title="Inviter des amis"
|
||||
left={<ArrowLeft size={20} onClick={() => navigate(`/events/${eventId}`)} style={{ cursor: 'pointer' }} />}
|
||||
left={<ArrowLeft size={20} onClick={() => navigate(eventPath(eventId ?? ''))} style={{ cursor: 'pointer' }} />}
|
||||
/>
|
||||
|
||||
<div style={{ padding: '12px 16px', borderBottom: '1px solid #f0f0f0' }}>
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState } from 'react';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { Button, Avatar, Input, showToast } from '../../../shared/components/sketchy';
|
||||
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
|
||||
import { useNavigate, useParams } from '../../../app/router';
|
||||
import { eventPath, useNavigate, useParams } from '../../../app/router';
|
||||
|
||||
export function MeetingPointsScreen() {
|
||||
const navigate = useNavigate();
|
||||
@@ -28,13 +28,13 @@ export function MeetingPointsScreen() {
|
||||
time: when || duration,
|
||||
});
|
||||
showToast(title ? `Point de rencontre créé : ${title}` : 'Point de rencontre créé', 'success');
|
||||
navigate(`/events/${eventId}`);
|
||||
navigate(eventPath(eventId ?? ''));
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div style={{ padding: '8px 16px', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<button onClick={() => navigate(`/events/${eventId}`)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#333', padding: 0, display: 'flex', alignItems: 'center' }}><ArrowLeft size={20} /></button>
|
||||
<button onClick={() => navigate(eventPath(eventId ?? ''))} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#333', padding: 0, display: 'flex', alignItems: 'center' }}><ArrowLeft size={20} /></button>
|
||||
<span style={{ fontSize: 17, fontWeight: 700 }}>Point de rencontre</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { Header, Avatar, Text, Input } from '../../../shared/components/sketchy';
|
||||
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
|
||||
import { useNavigate, useParams } from '../../../app/router';
|
||||
import { eventPath, useNavigate, useParams, userPath } from '../../../app/router';
|
||||
|
||||
const COLORS = ['#E8590C', '#2B6CB0', '#9C4DC7', '#38A169', '#D69E2E', '#E53E3E'];
|
||||
|
||||
@@ -43,7 +43,7 @@ export function ParticipantsListScreen() {
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<Header
|
||||
title={`Participants (${totalCount})`}
|
||||
left={<ArrowLeft size={20} onClick={() => navigate(`/events/${eventId}`)} style={{ cursor: 'pointer' }} />}
|
||||
left={<ArrowLeft size={20} onClick={() => navigate(eventPath(eventId ?? ''))} style={{ cursor: 'pointer' }} />}
|
||||
/>
|
||||
|
||||
<div style={{ padding: 16, borderBottom: '1px solid #f0f0f0' }}>
|
||||
@@ -54,7 +54,7 @@ export function ParticipantsListScreen() {
|
||||
{rows.map((p) => (
|
||||
<div
|
||||
key={p.key}
|
||||
onClick={p.known ? () => navigate(`/users/${p.key}`) : undefined}
|
||||
onClick={p.known ? () => navigate(userPath(p.key)) : undefined}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Header, Text, Input, Button, Placeholder, showToast } from '../../../shared/components/sketchy';
|
||||
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
|
||||
import { useNavigate, useParams } from '../../../app/router';
|
||||
import { eventPath, useNavigate, useParams } from '../../../app/router';
|
||||
|
||||
export function UpdateEventScreen() {
|
||||
const navigate = useNavigate();
|
||||
@@ -46,7 +46,7 @@ export function UpdateEventScreen() {
|
||||
}))
|
||||
.then(() => {
|
||||
showToast('Événement mis à jour', 'success');
|
||||
navigate(`/events/${eventId}`);
|
||||
navigate(eventPath(eventId ?? ''));
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.error('[UpdateEvent] event update failed:', err);
|
||||
@@ -61,7 +61,7 @@ export function UpdateEventScreen() {
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<Header
|
||||
title="Modifier l'événement"
|
||||
left={<span onClick={() => navigate(`/events/${eventId}`)} style={{ cursor: 'pointer', fontSize: 18 }}>✕</span>}
|
||||
left={<span onClick={() => navigate(eventPath(eventId ?? ''))} style={{ cursor: 'pointer', fontSize: 18 }}>✕</span>}
|
||||
/>
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}>
|
||||
<Text style={{ textAlign: 'center', color: '#888' }}>
|
||||
@@ -80,7 +80,7 @@ export function UpdateEventScreen() {
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<Header
|
||||
title="Modifier l'événement"
|
||||
left={<span onClick={() => navigate(`/events/${eventId}`)} style={{ cursor: 'pointer', fontSize: 18 }}>✕</span>}
|
||||
left={<span onClick={() => navigate(eventPath(eventId ?? ''))} style={{ cursor: 'pointer', fontSize: 18 }}>✕</span>}
|
||||
/>
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}>
|
||||
<Text aria-busy="true" style={{ textAlign: 'center', color: '#888' }}>
|
||||
@@ -95,7 +95,7 @@ export function UpdateEventScreen() {
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<Header
|
||||
title="Modifier l'événement"
|
||||
left={<span onClick={() => navigate(`/events/${eventId}`)} style={{ cursor: 'pointer', fontSize: 18 }}>✕</span>}
|
||||
left={<span onClick={() => navigate(eventPath(eventId ?? ''))} style={{ cursor: 'pointer', fontSize: 18 }}>✕</span>}
|
||||
/>
|
||||
|
||||
<div style={{ flex: 1, padding: 16, overflow: 'auto' }}>
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
import { Given, When, Then } from '@cucumber/cucumber';
|
||||
import { expect } from 'chai';
|
||||
import type { FestipodWorld } from '../../../../shared/support/world';
|
||||
import type { ApodsSession } from '../../../../shared/support/activitypodsStack';
|
||||
import {
|
||||
apodsStackConfig,
|
||||
attendeesCollectionUri,
|
||||
contextLink,
|
||||
createResource,
|
||||
linkId,
|
||||
podLogin,
|
||||
postOutboxActivity,
|
||||
readAttendees,
|
||||
readResource,
|
||||
recordUri,
|
||||
urlStatus,
|
||||
} from '../../../../shared/support/activitypodsStack';
|
||||
|
||||
/**
|
||||
* Steps for the `@e2e-activitypods` smoke layer: real HTTP against the local
|
||||
* ActivityPods stack (pod provider + Festipod backend) and, for `@browser`
|
||||
* scenarios, the real app in Playwright. Nothing here mocks the platform — the
|
||||
* point of the layer is that the flows actually round-trip through a Pod.
|
||||
*/
|
||||
|
||||
function session(world: FestipodWorld): ApodsSession {
|
||||
if (!world.apods.session) {
|
||||
throw new Error('No Pod session — a "session Pod locale" step must run first');
|
||||
}
|
||||
return world.apods.session;
|
||||
}
|
||||
|
||||
function requires(world: FestipodWorld, value: string | undefined, what: string): string {
|
||||
if (!value) throw new Error(`${what} is unset — a prior step must have created it`);
|
||||
return value;
|
||||
}
|
||||
|
||||
/** The ActivityPods context the Pod round-trips the `as:`/`apods:` CURIEs against. */
|
||||
const JSON_CONTEXT = 'https://activitypods.org/context.json';
|
||||
|
||||
function arrayOf(value: unknown): unknown[] {
|
||||
return value === undefined || value === null ? [] : Array.isArray(value) ? value : [value];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a resource in ONE of the Pod's registered containers (the Event or
|
||||
* Collection DataRegistration the app is granted on). The app can only WRITE
|
||||
* where its access needs were mapped to WAC rights at install time — a resource
|
||||
* created in the user's raw storage root is outside the grant, so the backend's
|
||||
* join/leave collection update is refused (403). Placement IS part of the
|
||||
* scenario, not an incidental detail of the helper.
|
||||
*/
|
||||
async function createInContainer(session: ApodsSession, containerUri: string, body: Record<string, unknown>): Promise<string> {
|
||||
const response = await fetch(containerUri, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/ld+json',
|
||||
Accept: 'application/ld+json',
|
||||
Authorization: `Bearer ${session.token}`,
|
||||
},
|
||||
body: JSON.stringify({ '@context': JSON_CONTEXT, ...body }),
|
||||
});
|
||||
const location = response.headers.get('Location') ?? response.headers.get('location');
|
||||
if (!response.ok || !location) {
|
||||
throw new Error(`Create in ${containerUri} failed: HTTP ${response.status} ${(await response.text()).slice(0, 200)}`);
|
||||
}
|
||||
return location;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a collection until `predicate` holds, or the timeout expires. The
|
||||
* backend's collection update lands ASYNCHRONOUSLY (the platform's own model —
|
||||
* see src/shared/data/outbox.ts), so a single read right after the Join POST
|
||||
* races the update; polling is what makes the assertion about reality, not luck.
|
||||
* Returns the last read on timeout so the caller's `expect` reports the truth.
|
||||
*/
|
||||
async function waitForMembers(
|
||||
session: ApodsSession,
|
||||
collectionUri: string,
|
||||
predicate: (members: unknown[]) => boolean,
|
||||
timeoutMs = 20000,
|
||||
): Promise<unknown[]> {
|
||||
const end = Date.now() + timeoutMs;
|
||||
let members: unknown[] = [];
|
||||
while (Date.now() < end) {
|
||||
members = await readAttendees(session, collectionUri);
|
||||
if (predicate(members)) return members;
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
}
|
||||
return members;
|
||||
}
|
||||
|
||||
// ── The stack is up ────────────────────────────────────────────────────────
|
||||
|
||||
Then("le pod provider local répond sur son endpoint OpenID", async function (this: FestipodWorld) {
|
||||
const { podProviderUrl } = apodsStackConfig();
|
||||
const status = await urlStatus(`${podProviderUrl}/.well-known/openid-configuration`);
|
||||
expect(status, `GET ${podProviderUrl}/.well-known/openid-configuration`).to.equal(200);
|
||||
});
|
||||
|
||||
Then("le backend Festipod sert la description de son application", async function (this: FestipodWorld) {
|
||||
const { backendUrl } = apodsStackConfig();
|
||||
const description = await readResource(undefined, `${backendUrl}/app`);
|
||||
// The description IS the OIDC client the app registers with: it must carry
|
||||
// the interop access-need group the Pod grants, not merely answer 200.
|
||||
expect(JSON.stringify(description.type)).to.contain('Application');
|
||||
expect(linkId(description['interop:hasAccessNeedGroup']), 'access-need group').to.be.a('string');
|
||||
});
|
||||
|
||||
// ── The app boots in the ActivityPods variant ──────────────────────────────
|
||||
|
||||
When("l'application Festipod est chargée depuis la pile locale", async function (this: FestipodWorld) {
|
||||
const appUrl = requires(this, this.apods.appUrl, 'app URL (set by the @browser Before hook)');
|
||||
expect(this.page, 'a Playwright page (set by the @browser Before hook)').to.not.be.null;
|
||||
await this.page!.goto(appUrl, { waitUntil: 'domcontentloaded' });
|
||||
// No session yet → AuthGate renders the Pod-provider picker.
|
||||
await this.page!.waitForSelector('text=Connectez-vous avec votre Pod', { timeout: 30000 });
|
||||
});
|
||||
|
||||
Then("l'entrée de connexion au Pod est proposée", async function (this: FestipodWorld) {
|
||||
const { podProviderUrl } = apodsStackConfig();
|
||||
const host = new URL(podProviderUrl).host;
|
||||
await this.page!.waitForSelector(`text=Se connecter avec ${host}`, { timeout: 30000 });
|
||||
});
|
||||
|
||||
Then("aucune erreur runtime n'a été émise au chargement", function (this: FestipodWorld) {
|
||||
expect(
|
||||
this.pageErrors,
|
||||
`Des erreurs runtime ont été émises au chargement de l'app :\n` +
|
||||
this.pageErrors.map((e, i) => ` [${i + 1}] ${e}`).join('\n'),
|
||||
).to.be.empty;
|
||||
});
|
||||
|
||||
// ── Event + meeting point, the as:context graft ────────────────────────────
|
||||
|
||||
Given(
|
||||
'une session Pod locale pour {string} avec le mot de passe {string}',
|
||||
async function (this: FestipodWorld, username: string, password: string) {
|
||||
this.apods.session = await podLogin(apodsStackConfig(), username, password);
|
||||
},
|
||||
);
|
||||
|
||||
Given("l'application Festipod est installée sur le Pod de la session", async function (this: FestipodWorld) {
|
||||
const pod = session(this);
|
||||
const config = apodsStackConfig();
|
||||
// The registered agent IS the Solid-OIDC client_id: the app actor URI the
|
||||
// backend serves as both the actor and its Client ID Document. Registering
|
||||
// against `/app` (the alias) is refused — the agent must be the actor URI.
|
||||
const description = await readResource(undefined, `${config.backendUrl}/app`);
|
||||
const appUri = requires(this, recordUri(description), 'app actor URI (the id of GET /app)');
|
||||
const groupUri = requires(this, linkId(description['interop:hasAccessNeedGroup']), 'access-need group URI');
|
||||
const group = await readResource(undefined, groupUri);
|
||||
const acceptedAccessNeeds = arrayOf(group['interop:hasAccessNeed']);
|
||||
const acceptedSpecialRights = arrayOf(group['apods:hasSpecialRights']);
|
||||
|
||||
const response = await fetch(`${config.podProviderUrl}/.auth-agent/app-registrations/register`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${pod.token}` },
|
||||
body: JSON.stringify({ appUri, acceptedAccessNeeds, acceptedSpecialRights }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
// Idempotent: a re-run finds the registration already in place. Any other
|
||||
// refusal is a real failure, reported verbatim.
|
||||
if (response.status === 400 && text.includes('already has an application registration')) return;
|
||||
throw new Error(`App installation failed: HTTP ${response.status} ${text.slice(0, 300)}`);
|
||||
}
|
||||
|
||||
// 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.
|
||||
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}` } })
|
||||
.then(r => r.status)
|
||||
.catch(() => 0);
|
||||
if (status === 200) return;
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
}
|
||||
throw new Error(`Registered container ${eventContainer} never appeared after install`);
|
||||
});
|
||||
|
||||
When(
|
||||
'je crée un événement {string} et un point de rencontre {string} rattaché par as:context',
|
||||
async function (this: FestipodWorld, title: string, meetingPointTitle: string) {
|
||||
const pod = session(this);
|
||||
const anchor = await createResource(pod, {
|
||||
type: 'Event',
|
||||
name: title,
|
||||
startTime: '2026-11-01T18:00:00Z',
|
||||
endTime: '2026-11-01T22:00:00Z',
|
||||
});
|
||||
this.apods.anchorUri = anchor;
|
||||
this.apods.meetingPointUri = await createResource(pod, {
|
||||
type: 'Event',
|
||||
name: meetingPointTitle,
|
||||
'as:context': anchor,
|
||||
organizer: pod.webId,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
Then(
|
||||
"la lecture du point de rencontre expose son lien as:context vers l'événement",
|
||||
async function (this: FestipodWorld) {
|
||||
const pod = session(this);
|
||||
const mpUri = requires(this, this.apods.meetingPointUri, 'meeting point URI');
|
||||
const record = await readResource(pod, mpUri);
|
||||
expect(contextLink(record)).to.equal(this.apods.anchorUri);
|
||||
},
|
||||
);
|
||||
|
||||
Then(
|
||||
"l'événement et le point de rencontre partagent le conteneur as:Event",
|
||||
async function (this: FestipodWorld) {
|
||||
const pod = session(this);
|
||||
const anchor = await readResource(pod, requires(this, this.apods.anchorUri, 'anchor URI'));
|
||||
const mp = await readResource(pod, requires(this, this.apods.meetingPointUri, 'meeting point URI'));
|
||||
// Both are as/Event, both created under the session's storage — so a single
|
||||
// container holds the two, and the ONLY distinction is the as:context link
|
||||
// the reader's bindings test.
|
||||
expect(JSON.stringify(anchor.type)).to.contain('Event');
|
||||
expect(JSON.stringify(mp.type)).to.contain('Event');
|
||||
expect(recordUri(anchor), 'anchor URI').to.match(new RegExp(`^${pod.storage}/`));
|
||||
expect(recordUri(mp), 'meeting point URI').to.match(new RegExp(`^${pod.storage}/`));
|
||||
expect(contextLink(anchor), 'an anchor event carries no as:context').to.be.undefined;
|
||||
expect(contextLink(mp), 'the meeting point carries as:context').to.be.a('string');
|
||||
},
|
||||
);
|
||||
|
||||
// ── Join / leave through the outbox (suspended in the feature) ─────────────
|
||||
|
||||
Given(
|
||||
'un point de rencontre {string} rattaché à un événement, avec sa collection apods:attendees',
|
||||
async function (this: FestipodWorld, title: string) {
|
||||
const pod = session(this);
|
||||
// Created in the Event DataRegistration the app was granted on at install.
|
||||
const anchor = await createInContainer(pod, `${pod.storage}/as/event`, { type: 'Event', name: `${title} — ancre` });
|
||||
// The `apods:attendees` collection is the APP BACKEND's to attach: when it
|
||||
// processes the meeting point's creation, `pod-collections-handler.onCreate`
|
||||
// creates and links it. Hand-attaching one (or creating the collection
|
||||
// ourselves) is NOT the app's path and trips an uncaught TypeError in
|
||||
// @activitypods/app — so we create the bare meeting point and let it attach.
|
||||
const mp = await createInContainer(pod, `${pod.storage}/as/event`, {
|
||||
type: 'Event',
|
||||
name: title,
|
||||
'as:context': anchor,
|
||||
organizer: pod.webId,
|
||||
});
|
||||
this.apods.anchorUri = anchor;
|
||||
this.apods.meetingPointUri = mp;
|
||||
// The attach is ASYNCHRONOUS (the backend reacts to the Pod activity), so the
|
||||
// collection URI is read back once the record carries it.
|
||||
const end = Date.now() + 20000;
|
||||
let collection: string | undefined;
|
||||
while (Date.now() < end) {
|
||||
collection = attendeesCollectionUri(await readResource(pod, mp));
|
||||
if (collection) break;
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
}
|
||||
if (!collection) throw new Error(`The meeting point ${mp} never gained an apods:attendees collection`);
|
||||
this.apods.collectionUri = collection;
|
||||
},
|
||||
);
|
||||
|
||||
When('la session rejoint le point de rencontre', async function (this: FestipodWorld) {
|
||||
const pod = session(this);
|
||||
const mpUri = requires(this, this.apods.meetingPointUri, 'meeting point URI');
|
||||
// The app's own path (src/shared/data/outbox.ts): as:Join to the user's own
|
||||
// outbox, `object` = the meeting point URI and `to` = the HOST (the record's
|
||||
// dc:creator). Without the addressee the backend silently drops the activity;
|
||||
// and an activity carrying no routing target never reaches the host.
|
||||
const host = linkId((await readResource(pod, mpUri))['dc:creator']) ?? pod.webId;
|
||||
await postOutboxActivity(pod, 'Join', mpUri, host);
|
||||
});
|
||||
|
||||
When('la session quitte le point de rencontre', async function (this: FestipodWorld) {
|
||||
const pod = session(this);
|
||||
const mpUri = requires(this, this.apods.meetingPointUri, 'meeting point URI');
|
||||
const host = linkId((await readResource(pod, mpUri))['dc:creator']) ?? pod.webId;
|
||||
await postOutboxActivity(pod, 'Leave', mpUri, host);
|
||||
});
|
||||
|
||||
Then('la collection apods:attendees contient la session', async function (this: FestipodWorld) {
|
||||
const pod = session(this);
|
||||
const collectionUri = requires(this, this.apods.collectionUri, 'attendees collection URI');
|
||||
const members = await waitForMembers(pod, collectionUri, found => found.map(linkId).includes(pod.webId));
|
||||
expect(members.map(linkId)).to.include(pod.webId);
|
||||
});
|
||||
|
||||
Then(
|
||||
'la collection apods:attendees ne contient plus la session',
|
||||
async function (this: FestipodWorld) {
|
||||
const pod = session(this);
|
||||
const collectionUri = requires(this, this.apods.collectionUri, 'attendees collection URI');
|
||||
const members = await waitForMembers(pod, collectionUri, found => !found.map(linkId).includes(pod.webId));
|
||||
expect(members.map(linkId)).to.not.include(pod.webId);
|
||||
},
|
||||
);
|
||||
|
||||
// ── Read-side derivations over the live collection (the Alice/Bob rule) ─────
|
||||
|
||||
Then('le compteur du point de rencontre compte cette inscription', async function (this: FestipodWorld) {
|
||||
const pod = session(this);
|
||||
const collectionUri = requires(this, this.apods.collectionUri, 'attendees collection URI');
|
||||
const members = await waitForMembers(pod, collectionUri, found => found.map(linkId).includes(pod.webId));
|
||||
const collection = await readResource(pod, collectionUri);
|
||||
// The count is the collection total — kept as-is, never the number of
|
||||
// RESOLVED profiles, so it stays total for a reader who resolves nobody.
|
||||
const raw = collection.totalItems ?? collection['as:totalItems'];
|
||||
const total = raw === undefined ? members.length : Number(raw);
|
||||
expect(total, 'the count reflects the joined session').to.be.greaterThan(0);
|
||||
expect(members.map(linkId), 'the counted session is the one listed').to.include(pod.webId);
|
||||
});
|
||||
|
||||
Then('la liste des participants rend ce participant comme anonyme', async function (this: FestipodWorld) {
|
||||
const pod = session(this);
|
||||
const collectionUri = requires(this, this.apods.collectionUri, 'attendees collection URI');
|
||||
const members = await waitForMembers(pod, collectionUri, found => found.map(linkId).includes(pod.webId));
|
||||
const joined = members.find(member => linkId(member) === pod.webId);
|
||||
expect(joined, 'the joined session is listed').to.not.be.undefined;
|
||||
// "Anonymous" is the read-side fact: the member travels as a REFERENCE with no
|
||||
// profile the reader can resolve — the list has nothing but the URI to render,
|
||||
// which is exactly what the app shows for a participant it cannot dereference.
|
||||
const profileFields = ['name', 'preferredUsername', 'as:name', 'nickname'];
|
||||
const hasProfile =
|
||||
typeof joined === 'object' && joined !== null && profileFields.some(field => (joined as Record<string, unknown>)[field] !== undefined);
|
||||
expect(hasProfile, 'a listed participant carries no resolved profile (rendered anonymous)').to.be.false;
|
||||
});
|
||||
@@ -77,13 +77,16 @@ Then('les événements affichent leur lieu', async function (this: FestipodWorld
|
||||
'Event cards should display a location (text or 📍 icon)').to.be.true;
|
||||
});
|
||||
|
||||
// The product model signs up to a MEETING POINT, not to the event (arbitration
|
||||
// of the event-join removal, T04.e): the signup affordance on this screen is the
|
||||
// per-meeting-point "S'inscrire" toggle, not an event-level join button.
|
||||
Then('je peux m\'inscrire à l\'événement', async function (this: FestipodWorld) {
|
||||
expect(this.currentScreenId).to.equal('event-detail');
|
||||
const doc = this.renderedDoc;
|
||||
expect(doc, 'Screen should be rendered').to.not.be.null;
|
||||
const buttons = Array.from(doc!.querySelectorAll('button')).map(b => b.textContent ?? '');
|
||||
const hasToggle = buttons.some(t => t.includes("J'y serai") || t.includes('Je participe'));
|
||||
expect(hasToggle, 'Event detail should expose a join/leave toggle button').to.be.true;
|
||||
const hasSignup = buttons.some(t => t.includes("S'inscrire") || t.includes('Inscrit'));
|
||||
expect(hasSignup, 'Event detail should expose a meeting-point signup button').to.be.true;
|
||||
});
|
||||
|
||||
Then('je peux me désinscrire de l\'événement', async function (this: FestipodWorld) {
|
||||
@@ -91,8 +94,8 @@ Then('je peux me désinscrire de l\'événement', async function (this: Festipod
|
||||
const doc = this.renderedDoc;
|
||||
expect(doc, 'Screen should be rendered').to.not.be.null;
|
||||
const buttons = Array.from(doc!.querySelectorAll('button')).map(b => b.textContent ?? '');
|
||||
const hasToggle = buttons.some(t => t.includes("J'y serai") || t.includes('Je participe'));
|
||||
expect(hasToggle, 'Event detail should expose a join/leave toggle button').to.be.true;
|
||||
const hasSignup = buttons.some(t => t.includes("S'inscrire") || t.includes('Inscrit'));
|
||||
expect(hasSignup, 'Event detail should expose a meeting-point signup toggle').to.be.true;
|
||||
});
|
||||
|
||||
// --- Create-event form assertions ---
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
#
|
||||
# Tout futur crash de rendu connecté = ce smoke passe au rouge.
|
||||
|
||||
@e2e @smoke
|
||||
@e2e @smoke @suspendu
|
||||
Fonctionnalité: L'accueil connecté rend du contenu réel
|
||||
En tant qu'utilisateur qui vient de se connecter
|
||||
Je veux que l'écran d'accueil rende réellement l'application
|
||||
|
||||
@@ -2,19 +2,32 @@ import { useState } from 'react';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { Header, Text, ListItem, Toggle, Divider, BottomNav } from '../../../shared/components/sketchy';
|
||||
import { useNavigate } from '../../../app/router';
|
||||
import { initialBackendName } from '../../../shared/refine/providers';
|
||||
import { logoutNg } from '../../../shared/utils/ngSession';
|
||||
import { setCurrentSession } from '../../../shared/utils/currentSession';
|
||||
import { logoutActivityPods } from '../../../app/auth/activityPodsAuth';
|
||||
|
||||
export function SettingsScreen() {
|
||||
const navigate = useNavigate();
|
||||
const [notifications, setNotifications] = useState(true);
|
||||
const [darkMode, setDarkMode] = useState(false);
|
||||
const [location, setLocation] = useState(true);
|
||||
const backendName = initialBackendName();
|
||||
|
||||
// The ONLY logout: stop the session of the wallet this deployment serves, which
|
||||
// forces a broker redirect on the next access. There is nothing app-side to sign
|
||||
// out of — the app never names an identity, so it holds none to drop.
|
||||
// Sign out of the session THIS deployment actually runs.
|
||||
//
|
||||
// ActivityPods keeps its OIDC token in storage, so the NextGraph no-op would
|
||||
// leave it in place and the gate would re-authenticate on the reload. The auth
|
||||
// provider's own `logout` clears it, and the app's session store is dropped too
|
||||
// so nothing re-reads the dead session in between. NextGraph (the suspended
|
||||
// variant) keeps its wallet-session stop.
|
||||
const handleLeaveEnvironment = async () => {
|
||||
await logoutNg();
|
||||
if (backendName === 'activitypods') {
|
||||
await logoutActivityPods();
|
||||
setCurrentSession(undefined);
|
||||
} else {
|
||||
await logoutNg();
|
||||
}
|
||||
if (typeof window !== 'undefined') window.location.reload();
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { Header, Text, Avatar, Input, Button, BottomNav } from '../../../shared/components/sketchy';
|
||||
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
|
||||
import { useNavigate } from '../../../app/router';
|
||||
import { useNavigate, userPath } from '../../../app/router';
|
||||
|
||||
const COLORS = ['#2B6CB0', '#9C4DC7', '#38A169', '#D69E2E', '#E53E3E', '#E8590C'];
|
||||
|
||||
@@ -25,7 +25,7 @@ export function FriendsListScreen() {
|
||||
{friends.map((person, i) => (
|
||||
<div
|
||||
key={person.id}
|
||||
onClick={() => navigate(`/users/${person.id}`)}
|
||||
onClick={() => navigate(userPath(person.id))}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Header, Avatar, Title, Text, Button, Card, Divider, BottomNav, Tag } from '../../../shared/components/sketchy';
|
||||
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
|
||||
import { useNavigate } from '../../../app/router';
|
||||
import { eventPath, useNavigate } from '../../../app/router';
|
||||
|
||||
export function ProfileScreen() {
|
||||
const navigate = useNavigate();
|
||||
@@ -70,7 +70,7 @@ export function ProfileScreen() {
|
||||
<div style={{ padding: 16 }}>
|
||||
<Text style={{ fontWeight: 'bold', marginBottom: 12 }}>Mes événements à venir</Text>
|
||||
{myEvents.slice(0, 3).map((event) => (
|
||||
<Card key={event.id} onClick={() => navigate(`/events/${event.id}`)} style={{ marginBottom: 12 }}>
|
||||
<Card key={event.id} onClick={() => navigate(eventPath(event.id))} style={{ marginBottom: 12 }}>
|
||||
<Text className="user-content" style={{ margin: 0, fontWeight: 'bold' }}>{event.title}</Text>
|
||||
<Text style={{ margin: '4px 0 0 0', fontSize: 13, color: '#888' }}>{event.date}</Text>
|
||||
</Card>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { Header, Avatar, Title, Text, Button, Card, Badge, Divider } from '../../../shared/components/sketchy';
|
||||
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
|
||||
import { useNavigate, useGoBack, useParams } from '../../../app/router';
|
||||
import { eventPath, useNavigate, useGoBack, useParams } from '../../../app/router';
|
||||
|
||||
export function UserProfileScreen() {
|
||||
const navigate = useNavigate();
|
||||
@@ -65,7 +65,7 @@ export function UserProfileScreen() {
|
||||
<div style={{ padding: 16 }}>
|
||||
<Text style={{ fontWeight: 'bold', marginBottom: 12 }}>Événements à venir</Text>
|
||||
{userEvents.map((event) => (
|
||||
<Card key={event.id} onClick={() => navigate(`/events/${event.id}`)} style={{ marginBottom: 12 }}>
|
||||
<Card key={event.id} onClick={() => navigate(eventPath(event.id))} style={{ marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<div>
|
||||
<Text className="user-content" style={{ margin: 0, fontWeight: 'bold' }}>{event.title}</Text>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# language: fr
|
||||
@data @multibrowser
|
||||
@data @multibrowser @suspendu
|
||||
Fonctionnalité: Harness multi-navigateur — modèle shared-wallet
|
||||
Pour valider le provisioning du wallet partagé sur plusieurs navigateurs
|
||||
En tant que développeur du stopgap puis de la cible NextGraph
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { resolveDataProviderMode } from './dataProviderMode';
|
||||
|
||||
const base = {
|
||||
ngStatus: 'disconnected' as const,
|
||||
signedIn: false,
|
||||
hasRefineSession: false,
|
||||
backendName: 'activitypods',
|
||||
};
|
||||
|
||||
// A live NextGraph session wins over everything — this is how the @data broker
|
||||
// harness (which mounts NextGraphProvider at the root URL) keeps its path even
|
||||
// though the URL names the default ActivityPods backend.
|
||||
test('a connecting or connected-but-unsigned NG session shows an empty local state', () => {
|
||||
expect(resolveDataProviderMode({ ...base, ngStatus: 'connecting' })).toBe('local-empty');
|
||||
expect(resolveDataProviderMode({ ...base, ngStatus: 'connected', signedIn: false })).toBe('local-empty');
|
||||
});
|
||||
|
||||
test('a connected and signed-in NG session routes to the polyfill path', () => {
|
||||
expect(resolveDataProviderMode({ ...base, ngStatus: 'connected', signedIn: true })).toBe('ng');
|
||||
expect(resolveDataProviderMode({ ...base, ngStatus: 'connected', signedIn: true, hasRefineSession: true })).toBe('ng');
|
||||
});
|
||||
|
||||
test('an ActivityPods session with a registered backend routes to refine', () => {
|
||||
expect(resolveDataProviderMode({ ...base, hasRefineSession: true })).toBe('refine');
|
||||
expect(resolveDataProviderMode({ ...base, hasRefineSession: true, backendName: undefined })).toBe('local');
|
||||
});
|
||||
|
||||
test('no session at all serves the seeded demo (offline, unconfigured backend)', () => {
|
||||
expect(resolveDataProviderMode(base)).toBe('local');
|
||||
expect(resolveDataProviderMode({ ...base, backendName: undefined })).toBe('local');
|
||||
});
|
||||
|
||||
test('an NG error without a refine session falls back to the demo, like disconnected', () => {
|
||||
expect(resolveDataProviderMode({ ...base, ngStatus: 'error' })).toBe('local');
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Which data path a page runs — the dispatcher's decision, pure.
|
||||
*
|
||||
* The rule is SESSION REALITY FIRST, backend name second:
|
||||
*
|
||||
* - A live NextGraph session wins — this covers the `nextgraph` URL variant
|
||||
* AND the `@data` broker harness, which mounts `NextGraphProvider` directly
|
||||
* at the root URL. While it connects, or once connected but before the one
|
||||
* identity await settles, the app shows an EMPTY local state: no reads may
|
||||
* fire before "who am I" is answered.
|
||||
* - Otherwise, an ActivityPods session (published by AuthGate) routes to the
|
||||
* registered Refine backend — the default. Only the session distinguishes
|
||||
* "authenticated" from "offline demo": with a session the screens read the
|
||||
* Pod; without one (unconfigured backend, "Continuer hors ligne") they get
|
||||
* the seeded demo.
|
||||
*/
|
||||
|
||||
export type DataProviderMode = 'ng' | 'refine' | 'local-empty' | 'local';
|
||||
|
||||
export interface DataProviderModeInput {
|
||||
/** The NextGraph connection status ('disconnected' when no provider is mounted). */
|
||||
ngStatus: 'disconnected' | 'connecting' | 'connected' | 'error';
|
||||
/** Whether the one identity await has settled (NextGraph path). */
|
||||
signedIn: boolean;
|
||||
/** Whether an ActivityPods session exists. */
|
||||
hasRefineSession: boolean;
|
||||
/** The backend the URL selected (`undefined` when none is registered). */
|
||||
backendName: string | undefined;
|
||||
}
|
||||
|
||||
export function resolveDataProviderMode(input: DataProviderModeInput): DataProviderMode {
|
||||
const { ngStatus, signedIn, hasRefineSession, backendName } = input;
|
||||
|
||||
if (ngStatus === 'connecting') return 'local-empty';
|
||||
if (ngStatus === 'connected') {
|
||||
// The NG harness and the nextgraph variant both gate on the identity
|
||||
// settling before exposing anything; the data path follows the same rule.
|
||||
return signedIn ? 'ng' : 'local-empty';
|
||||
}
|
||||
|
||||
// No live NextGraph session: the polyfill path is not mounted (its status
|
||||
// stays 'disconnected' without a provider, including at the root variant).
|
||||
if (hasRefineSession && backendName) return 'refine';
|
||||
return 'local';
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* FestipodDataContext — the screen-facing data contract, and the shared pieces
|
||||
* every provider implementation builds on.
|
||||
*
|
||||
* The app signs up to a MEETING POINT, not to an event (product model): an
|
||||
* event is only the public anchor meeting points graft onto. So the
|
||||
* participation surface is `joinMeetingPoint` / `leaveMeetingPoint`; there is
|
||||
* no event-level join. Provider implementations:
|
||||
*
|
||||
* - `LocalDataProvider` (`FestipodDataContext.tsx`) — the disconnected demo,
|
||||
* seed fixtures, mutations as no-ops. What the `@ui` layer renders against.
|
||||
* - `RefineDataProvider` (`refineData.tsx`) — the ActivityPods backend behind
|
||||
* the Refine seam: CRUD through the data provider, joins/leaves through the
|
||||
* outbox service beside it.
|
||||
* - `NgDataProvider` (`ngData.tsx`) — the suspended NextGraph+polyfill path,
|
||||
* preserved for the `nextgraph` URL variant; NOT mounted at the root.
|
||||
*/
|
||||
|
||||
import { createContext, useContext } from 'react';
|
||||
import type {
|
||||
FpEventData,
|
||||
FpUserData,
|
||||
FpParticipationData,
|
||||
FpMeetingPointData,
|
||||
FpFriendshipData,
|
||||
FpNotificationData,
|
||||
} from '../data/types';
|
||||
import type { BootstrapResult } from '../utils/ngBootstrap';
|
||||
import { normalizeIdentifier } from '../utils/identifier';
|
||||
|
||||
// ============================================================================
|
||||
// Context interface
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Whether this session may WRITE an event's document — asked the same way on
|
||||
* every backend: only a record's owner writes it. `'unknown'` is a REAL third
|
||||
* answer (the read that settles it may not have landed yet), never a polite
|
||||
* `'not-mine'`: collapsing it is how an owner gets told, silently and wrongly,
|
||||
* that the thing is not theirs.
|
||||
*/
|
||||
export type EventOwnership = 'mine' | 'not-mine' | 'unknown';
|
||||
|
||||
export interface FestipodDataContextValue {
|
||||
currentUserId: string;
|
||||
currentUser: FpUserData | undefined;
|
||||
/**
|
||||
* WHO THE SESSION SIGNED IN AS — the identity signing in returned, known
|
||||
* before any document is read. For display (and log attribution) only: it is
|
||||
* a different id space from `currentUserId` (a profile document URI), it is
|
||||
* never written into an entity, and no data call takes it.
|
||||
*/
|
||||
currentPrincipal: string;
|
||||
|
||||
events: FpEventData[];
|
||||
users: FpUserData[];
|
||||
participations: FpParticipationData[];
|
||||
meetingPoints: FpMeetingPointData[];
|
||||
friendships: FpFriendshipData[];
|
||||
/** Host-facing notifications. Empty on backends without an inbox read. */
|
||||
notifications: FpNotificationData[];
|
||||
|
||||
getEvent(id: string): FpEventData | undefined;
|
||||
getUser(id: string): FpUserData | undefined;
|
||||
getEventParticipants(eventId: string): FpUserData[];
|
||||
getUserEvents(userId: string): FpEventData[];
|
||||
/** Membership test against a MEETING POINT id (defaults to the current user). */
|
||||
isParticipating(meetingPointId: string, userId?: string): boolean;
|
||||
getFriends(userId?: string): FpUserData[];
|
||||
getEventMeetingPoints(eventId: string): FpMeetingPointData[];
|
||||
/**
|
||||
* May this session WRITE this event? Callers MUST treat `'unknown'` as its
|
||||
* own case — see {@link EventOwnership}.
|
||||
*/
|
||||
getEventOwnership(eventId: string): EventOwnership;
|
||||
|
||||
selectedEventId: string;
|
||||
setSelectedEventId(id: string): void;
|
||||
selectedEvent: FpEventData | undefined;
|
||||
selectedUserId: string;
|
||||
setSelectedUserId(id: string): void;
|
||||
selectedUser: FpUserData | undefined;
|
||||
|
||||
createEvent(event: Omit<FpEventData, 'id'>): Promise<FpEventData>;
|
||||
updateEvent(id: string, updates: Partial<FpEventData>): void | Promise<void>;
|
||||
/**
|
||||
* Sign up to a meeting point. Backend-specific delivery (outbox `as:Join` on
|
||||
* ActivityPods; a participation record on the suspended NextGraph path), one
|
||||
* product fact everywhere: afterwards this user counts among the meeting
|
||||
* point's participants.
|
||||
*/
|
||||
joinMeetingPoint(meetingPointId: string, userId?: string): Promise<void> | void;
|
||||
/** Withdraw from a meeting point — the symmetric fact to `joinMeetingPoint`. */
|
||||
leaveMeetingPoint(meetingPointId: string, userId?: string): Promise<void> | void;
|
||||
addMeetingPoint(mp: Omit<FpMeetingPointData, 'id'>): void;
|
||||
addFriend(friendId: string): void;
|
||||
updateProfile(updates: Partial<FpUserData>): void | Promise<void>;
|
||||
loadTestData(): Promise<BootstrapResult>;
|
||||
}
|
||||
|
||||
export const FestipodDataContext = createContext<FestipodDataContextValue | null>(null);
|
||||
|
||||
// ============================================================================
|
||||
// Helpers shared by every provider
|
||||
// ============================================================================
|
||||
|
||||
let idCounter = 100;
|
||||
export function nextId(prefix: string): string {
|
||||
return `${prefix}-${++idCounter}`;
|
||||
}
|
||||
|
||||
// LEGACY, READ ONLY. Participations written by an older version key their user
|
||||
// (`fp:user`) as `urn:festipod:user:<key>` instead of the UserProfile's
|
||||
// `did:ng:` document NURI. Nothing MINTS this any more — today's writes carry
|
||||
// the profile NURI (`currentUserId`) — but `resolveParticipantUser` must still
|
||||
// recognize the old form, or those participations render as "participant
|
||||
// inconnu".
|
||||
export const USER_PRINCIPAL_PREFIX = 'urn:festipod:user:';
|
||||
|
||||
/**
|
||||
* Resolve a Participation's `fp:user` to its UserProfile. TODAY'S WRITES need
|
||||
* no resolving (`fp:user` carries the profile's own document id, the direct
|
||||
* match — tried first). LEGACY ONLY: `urn:festipod:user:<handle>` matches no
|
||||
* profile id, so the handle is stripped and compared to the normalized
|
||||
* username — a READ-side survival for old data, playing no part in deciding
|
||||
* who the current user is.
|
||||
*/
|
||||
export function resolveParticipantUser(userId: string, users: FpUserData[]): FpUserData | undefined {
|
||||
const direct = users.find(u => u.id === userId);
|
||||
if (direct) return direct;
|
||||
const key = userId.startsWith(USER_PRINCIPAL_PREFIX)
|
||||
? userId.slice(USER_PRINCIPAL_PREFIX.length)
|
||||
: userId;
|
||||
return users.find(u => u.username && normalizeIdentifier(u.username) === key);
|
||||
}
|
||||
|
||||
/**
|
||||
* What a BRAND-NEW profile is created with — "not filled in yet" placeholders,
|
||||
* replaced by the user through `updateProfile`. A profile is the app's own
|
||||
* object and signing in produces none of it; nothing here is derived from the
|
||||
* signed-in identity.
|
||||
*/
|
||||
export const UNSET_PROFILE = {
|
||||
name: 'Profil à compléter',
|
||||
initials: '?',
|
||||
username: '(pseudo non défini)',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* The shared in-memory queries — the same logic for every provider, fed with
|
||||
* that provider's arrays. Cross-entity derivations only: nothing here touches
|
||||
* a backend.
|
||||
*
|
||||
* THE `participations` ARRAY'S `eventId` IS THE JOINED TARGET'S ID. On the
|
||||
* local/NextGraph providers that is an event; on the ActivityPods provider it
|
||||
* is a meeting point. `isParticipating` and `getUserEvents` filter on it
|
||||
* as-is, so a provider whose participations are keyed differently (the
|
||||
* ActivityPods reader derives event-level participants from the meeting
|
||||
* points) overrides those two queries and reuses the rest.
|
||||
*/
|
||||
export function buildQueries(
|
||||
events: FpEventData[],
|
||||
users: FpUserData[],
|
||||
participations: FpParticipationData[],
|
||||
meetingPoints: FpMeetingPointData[],
|
||||
friendships: FpFriendshipData[],
|
||||
currentUserId: string,
|
||||
) {
|
||||
const getEvent = (id: string) => events.find(e => e.id === id);
|
||||
const getUser = (id: string) => users.find(u => u.id === id);
|
||||
|
||||
const getEventParticipants = (eventId: string) => {
|
||||
// Resolve each participation's `user` to its UserProfile across the id
|
||||
// spaces — see `resolveParticipantUser`. Deduped, returned in `users`
|
||||
// order. Whoever stays unresolved (a profile the reader may not read) is
|
||||
// NOT here — the count on the event is the total, the screens render the
|
||||
// remainder as generic participants.
|
||||
const eventParts = participations.filter(p => p.eventId === eventId);
|
||||
const resolvedIds = new Set<string>();
|
||||
for (const p of eventParts) {
|
||||
const u = resolveParticipantUser(p.userId, users);
|
||||
if (u) resolvedIds.add(u.id);
|
||||
}
|
||||
return users.filter(u => resolvedIds.has(u.id));
|
||||
};
|
||||
|
||||
const getUserEvents = (userId: string) => {
|
||||
const partEventIds = participations.filter(p => p.userId === userId).map(p => p.eventId);
|
||||
return events.filter(e => partEventIds.includes(e.id));
|
||||
};
|
||||
|
||||
const isParticipating = (meetingPointId: string, userId?: string) => {
|
||||
const uid = userId || currentUserId;
|
||||
return participations.some(p => p.eventId === meetingPointId && p.userId === uid);
|
||||
};
|
||||
|
||||
const getFriends = (userId?: string) => {
|
||||
const uid = userId || currentUserId;
|
||||
const friendIds = friendships
|
||||
.filter(f => f.userId === uid || f.friendId === uid)
|
||||
.map(f => f.userId === uid ? f.friendId : f.userId);
|
||||
return users.filter(u => friendIds.includes(u.id));
|
||||
};
|
||||
|
||||
const getEventMeetingPoints = (eventId: string) => {
|
||||
return meetingPoints.filter(mp => mp.eventId === eventId);
|
||||
};
|
||||
|
||||
return { getEvent, getUser, getEventParticipants, getUserEvents, isParticipating, getFriends, getEventMeetingPoints };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Hook
|
||||
// ============================================================================
|
||||
|
||||
export function useFestipodData() {
|
||||
const context = useContext(FestipodDataContext);
|
||||
if (!context) {
|
||||
throw new Error('useFestipodData must be used within a FestipodDataProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,494 @@
|
||||
/**
|
||||
* RefineDataProvider — the ActivityPods data path, behind the Refine seam.
|
||||
*
|
||||
* MOUNTED ONLY WHEN THE DISPATCHER (`FestipodDataContext.tsx`) has both a
|
||||
* registered Refine backend and an authenticated session to read through —
|
||||
* the AuthGate above guarantees the session exists before this tree mounts.
|
||||
*
|
||||
* WHAT GOES THROUGH REFINE. Everything CRUD: the event list (`useList` over
|
||||
* the `as:Event` container — the pod provider is own-pod scoped, so the list
|
||||
* is what THIS user relayed; cross-pod discovery is the catalog's job, a
|
||||
* follow-up behind this same seam), the meeting point create, the event and
|
||||
* profile updates. Records are split into events vs meeting points BY THE
|
||||
* BINDING (`isInstance`) — the two share one container and `as:context`
|
||||
* presence is the only distinction.
|
||||
*
|
||||
* WHAT LIVES BESIDE REFINE. A participation is not a CRUD write and the
|
||||
* refine-providers package implements no `custom` method: joining or leaving
|
||||
* a meeting point POSTs an `as:Join`/`as:Leave` activity to the user's own
|
||||
* outbox (`data/outbox.ts`), and the on-behalf backend maintains the
|
||||
* `apods:attendees` collection. The collection travels inside the event
|
||||
* record and is read by the participation binding (`membersOf`). A write made
|
||||
* beside Refine does not refresh its cache, and the backend updates the
|
||||
* collection asynchronously — so the join/leave reflects in an optimistic
|
||||
* overlay here and the lists are invalidated for the write to land when it
|
||||
* lands.
|
||||
*
|
||||
* THE COUNT AND THE ALICE/BOB RULE. The participant count of a meeting point
|
||||
* is the collection's total — never the number of RESOLVED profiles, so it
|
||||
* stays total for a reader who may read no profile. Resolving an attendee to
|
||||
* a person is two fetches (actor document → profile) whose second is
|
||||
* WAC-gated: a non-contact's refusal is absorbed in `data/actor.ts` and the
|
||||
* screen renders a generic participant. `getUser` is a pure lookup over what
|
||||
* resolved — undefined for everyone else.
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useEffect, useMemo, useRef, type ReactNode } from 'react';
|
||||
import { useList, useOne, useCreate, useUpdate, useInvalidate } from '@refinedev/core';
|
||||
import type { FpEventData, FpUserData, FpMeetingPointData } from '../data/types';
|
||||
import { fetchActorDocument, fetchActorProfileRecord } from '../data/actor';
|
||||
import { postJoinActivity, postLeaveActivity } from '../data/outbox';
|
||||
import { useCurrentSession } from '../utils/currentSession';
|
||||
import { useCurrentPrincipal } from '../utils/currentPrincipal';
|
||||
import type { BootstrapResult } from '../utils/ngBootstrap';
|
||||
import { RefineProvider, useBackend } from '../refine/RefineProvider';
|
||||
import type { RefineBackend } from '../refine/providers';
|
||||
import {
|
||||
FestipodDataContext,
|
||||
buildQueries,
|
||||
resolveParticipantUser,
|
||||
nextId,
|
||||
type FestipodDataContextValue,
|
||||
type EventOwnership,
|
||||
} from './festipodDataBase';
|
||||
import {
|
||||
applyMembershipOverlay,
|
||||
deriveContainer,
|
||||
type MembershipOverlay,
|
||||
} from './refineDerive';
|
||||
|
||||
export function RefineDataProvider({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<RefineProvider>
|
||||
<RefineDataInner>{children}</RefineDataInner>
|
||||
</RefineProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function RefineDataInner({ children }: { children: ReactNode }) {
|
||||
const backend = useBackend();
|
||||
if (!backend) {
|
||||
// Unreachable through the dispatcher (a session implies a registered
|
||||
// backend); render an inert value rather than a tree whose data hook
|
||||
// throws — nothing here may look like an empty wallet either.
|
||||
console.error('[refine] RefineDataProvider mounted with no registered backend');
|
||||
return <FestipodDataContext.Provider value={inertDataValue()}>{children}</FestipodDataContext.Provider>;
|
||||
}
|
||||
return <RefineDataMounted backend={backend}>{children}</RefineDataMounted>;
|
||||
}
|
||||
|
||||
function RefineDataMounted({ backend, children }: { backend: RefineBackend; children: ReactNode }) {
|
||||
const data = useRefineData(backend);
|
||||
return <FestipodDataContext.Provider value={data}>{children}</FestipodDataContext.Provider>;
|
||||
}
|
||||
|
||||
/** A do-nothing context value for the unreachable no-backend mount. */
|
||||
function inertDataValue(): FestipodDataContextValue {
|
||||
const none = {
|
||||
currentUserId: '', currentUser: undefined, currentPrincipal: '',
|
||||
events: [], users: [], participations: [], meetingPoints: [], friendships: [], notifications: [],
|
||||
selectedEventId: '', selectedEvent: undefined, selectedUserId: '', selectedUser: undefined,
|
||||
getEvent: () => undefined, getUser: () => undefined,
|
||||
getEventParticipants: () => [], getUserEvents: () => [], isParticipating: () => false,
|
||||
getFriends: () => [], getEventMeetingPoints: () => [], getEventOwnership: (): EventOwnership => 'unknown',
|
||||
setSelectedEventId: () => {}, setSelectedUserId: () => {},
|
||||
createEvent: async (event: Omit<FpEventData, 'id'>) => ({ ...event, id: nextId('event') }),
|
||||
updateEvent: () => {}, joinMeetingPoint: () => {}, leaveMeetingPoint: () => {},
|
||||
addMeetingPoint: () => {}, addFriend: () => {}, updateProfile: () => {}, loadTestData: async () => emptyBootstrap(),
|
||||
};
|
||||
return none;
|
||||
}
|
||||
|
||||
function emptyBootstrap(): BootstrapResult {
|
||||
return { seeded: false, userIdMap: new Map(), eventIdMap: new Map(), createdDocs: { public: [], protected: [] } };
|
||||
}
|
||||
|
||||
function useRefineData(backend: RefineBackend): FestipodDataContextValue {
|
||||
const bindings = backend.bindings;
|
||||
const session = useCurrentSession();
|
||||
const webId = session?.webId;
|
||||
const token = session?.token;
|
||||
|
||||
// WHO AM I — the Solid identity the session signed in as. On this backend the
|
||||
// ACTOR URI (`webId`) is the canonical user key: the attendees collection
|
||||
// carries actor URIs, so `currentUserId` is the actor URI too, and the profile
|
||||
// record fetched for display is stamped with it (its own document URI is not
|
||||
// the identity this app compares memberships against). `currentPrincipal` is
|
||||
// display/log attribution only.
|
||||
const currentPrincipal = useCurrentPrincipal();
|
||||
|
||||
// --- READS over the shared as/Event container ------------------------------
|
||||
// ONE list for both entities: events and meeting points resolve the same
|
||||
// container, so a single fetch serves both and the bindings split it.
|
||||
// `enabled: !!session` — pre-session this path renders empty rather than
|
||||
// firing unauthenticated queries.
|
||||
const containerQuery = useList({
|
||||
resource: bindings.event.resource,
|
||||
queryOptions: { enabled: !!session, retry: false },
|
||||
});
|
||||
const rawRecords: unknown[] = useMemo(
|
||||
() => (containerQuery.result.data ?? []) as unknown[],
|
||||
[containerQuery.result.data],
|
||||
);
|
||||
const containerFailed = containerQuery.query.isError;
|
||||
|
||||
// --- SPLIT + PARTICIPATION/COUNT DERIVATION (vocabulary lives in bindings) --
|
||||
const derived = useMemo(() => deriveContainer(rawRecords, bindings), [rawRecords, bindings]);
|
||||
|
||||
// --- OPTIMISTIC MEMBERSHIP OVERLAY ------------------------------------------
|
||||
// `watchShape`-style latency, Refine edition: the outbox post returns before
|
||||
// the backend has maintained the collection, and the container re-fetch can
|
||||
// still precede it. The overlay reflects the join/leave immediately and
|
||||
// reconciles away once the read carries the truth — no polling.
|
||||
const [overlay, setOverlay] = useState<MembershipOverlay>(new Map());
|
||||
|
||||
const participations = useMemo(
|
||||
() => applyMembershipOverlay(derived.participations, overlay, webId),
|
||||
[derived.participations, overlay, webId],
|
||||
);
|
||||
|
||||
// RECONCILIATION — drop each overlay entry once the container read carries
|
||||
// what it asserts (join: membership present; leave: membership absent).
|
||||
useEffect(() => {
|
||||
if (overlay.size === 0 || !webId) return;
|
||||
setOverlay(prev => {
|
||||
let changed = false;
|
||||
const next = new Map(prev);
|
||||
for (const [mpId, op] of prev) {
|
||||
const present = derived.participations.some(p => p.eventId === mpId && p.userId === webId);
|
||||
if ((op === 'join' && present) || (op === 'leave' && !present)) {
|
||||
next.delete(mpId);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, [derived.participations, overlay, webId]);
|
||||
|
||||
// --- MY PROFILE (the app's own entity id space) ------------------------------
|
||||
// The actor (WebID) document links the `as:Profile` record via `url`; the
|
||||
// profile read is a plain getOne on that URI. No profile link (or an
|
||||
// unreadable profile) → no currentUser: `updateProfile` refuses loudly
|
||||
// rather than landing edits on a stranger's document.
|
||||
const [profileUri, setProfileUri] = useState<string>('');
|
||||
useEffect(() => {
|
||||
if (!webId || !token) return;
|
||||
let cancelled = false;
|
||||
fetchActorDocument(webId, token)
|
||||
.then(actor => {
|
||||
if (!cancelled) setProfileUri(actor.profileUri ?? '');
|
||||
})
|
||||
.catch(err => console.error('[refine] actor document read failed:', err));
|
||||
return () => { cancelled = true; };
|
||||
}, [webId, token]);
|
||||
|
||||
const myProfileQuery = useOne({
|
||||
resource: bindings.profile.resource,
|
||||
id: profileUri,
|
||||
queryOptions: { enabled: !!profileUri && !!session, retry: false },
|
||||
});
|
||||
const myProfile: FpUserData | undefined = useMemo(() => {
|
||||
const record: unknown = myProfileQuery.result;
|
||||
if (record === undefined) return undefined;
|
||||
const domain = bindings.profile.toDomain(record);
|
||||
// Stamp the ACTOR URI as the id: it is the key memberships and every other
|
||||
// user reference live in. The profile's own document URI (which the binding
|
||||
// resolves) is deliberately not the id the app compares against.
|
||||
return webId ? { ...domain, id: webId } : domain;
|
||||
}, [myProfileQuery.result, bindings, webId]);
|
||||
const currentUser = myProfile;
|
||||
const currentUserId = myProfile?.id ?? '';
|
||||
|
||||
// --- PARTICIPANT RESOLUTION (the Alice/Bob rule) ------------------------------
|
||||
// Attendee refs are actor URIs; resolving them to displayable profiles is
|
||||
// two fetches whose second is WAC-gated. Refusals are absorbed in
|
||||
// `data/actor.ts` (undefined) — a non-contact renders as a generic
|
||||
// participant and the TOTAL above stays the collection's count.
|
||||
const [resolvedProfiles, setResolvedProfiles] = useState<FpUserData[]>([]);
|
||||
const [unresolved, setUnresolved] = useState<ReadonlySet<string>>(new Set());
|
||||
const attendeeIds = useMemo(
|
||||
() => [...new Set(participations.map(p => p.userId))].filter(id => id !== webId),
|
||||
[participations, webId],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
const missing = attendeeIds.filter(id => !unresolved.has(id) && !resolvedProfiles.some(u => u.id === id));
|
||||
if (missing.length === 0) return;
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
const fresh: FpUserData[] = [];
|
||||
const refused: string[] = [];
|
||||
for (const id of missing) {
|
||||
const record = await fetchActorProfileRecord(id, token);
|
||||
if (record === undefined) {
|
||||
refused.push(id);
|
||||
} else {
|
||||
// Stamp the ACTOR URI the record was fetched for — the same key an
|
||||
// attendee reference carries — so `resolveParticipantUser` can match
|
||||
// it. The profile document URI differs and would never match.
|
||||
fresh.push({ ...bindings.profile.toDomain(record), id });
|
||||
}
|
||||
}
|
||||
if (cancelled) return;
|
||||
if (fresh.length) {
|
||||
setResolvedProfiles(prev => [...prev, ...fresh]);
|
||||
// A successful fetch is a sign that access may have been granted since
|
||||
// an earlier refusal: drop the refusal marks for the attendees still in
|
||||
// scope, so a later pass retries them — a refusal is not permanent for
|
||||
// the page's life.
|
||||
setUnresolved(prev => {
|
||||
if (prev.size === 0) return prev;
|
||||
const next = new Set([...prev].filter(id => !attendeeIds.includes(id)));
|
||||
return next.size === prev.size ? prev : next;
|
||||
});
|
||||
}
|
||||
if (refused.length) setUnresolved(prev => new Set([...prev, ...refused]));
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
// resolvedProfiles is read to compute `missing` but not listed: listing it
|
||||
// would re-run the effect on every resolution it itself performs.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [attendeeIds, unresolved, token, bindings]);
|
||||
|
||||
const users = useMemo(() => {
|
||||
const all = myProfile ? [myProfile, ...resolvedProfiles] : resolvedProfiles;
|
||||
const seen = new Set<string>();
|
||||
return all.filter(u => (seen.has(u.id) ? false : (seen.add(u.id), true)));
|
||||
}, [myProfile, resolvedProfiles]);
|
||||
|
||||
// --- Selection (same state contract as every provider) -----------------------
|
||||
const [selectedEventId, setSelectedEventId] = useState<string>('');
|
||||
const [selectedUserId, setSelectedUserId] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
const first = derived.events[0];
|
||||
if (!selectedEventId && first) setSelectedEventId(first.id);
|
||||
}, [derived.events, selectedEventId]);
|
||||
|
||||
// Identity-first log prefix — same shape as the other providers.
|
||||
const logPrefix = currentUserId || currentPrincipal
|
||||
? `[${currentUserId || currentPrincipal}][app][data]`
|
||||
: '[app][data]';
|
||||
|
||||
// --- Event-level derivations over meeting-point memberships -------------------
|
||||
const mpIdsOf = (eventId: string) =>
|
||||
derived.meetingPoints.filter(mp => mp.eventId === eventId).map(mp => mp.id);
|
||||
|
||||
// Shared queries whose participation semantics already match (memberships are
|
||||
// keyed by the joined target's id — here, the meeting point). `currentUserId`
|
||||
// passed here is the MEMBERSHIP default key, which on this backend is the
|
||||
// actor URI, not the profile URI.
|
||||
const shared = buildQueries(
|
||||
derived.events,
|
||||
users,
|
||||
participations,
|
||||
derived.meetingPoints,
|
||||
[],
|
||||
webId ?? '',
|
||||
);
|
||||
|
||||
const getEventParticipants = useCallback((eventId: string) => {
|
||||
// The event's participants are the union across its meeting points'
|
||||
// memberships, resolved to profiles. Unresolved attendees are NOT here —
|
||||
// the count on the event stays the total.
|
||||
const ids = new Set<string>();
|
||||
for (const mpId of mpIdsOf(eventId)) {
|
||||
for (const p of participations.filter(part => part.eventId === mpId)) {
|
||||
const u = resolveParticipantUser(p.userId, users);
|
||||
if (u) ids.add(u.id);
|
||||
}
|
||||
}
|
||||
return users.filter(u => ids.has(u.id));
|
||||
}, [derived.meetingPoints, participations, users]);
|
||||
|
||||
const getUserEvents = useCallback((userId: string) => {
|
||||
// Answerable for the signed-in user only: memberships are what this
|
||||
// session can read. Someone else's events-by-participation are not
|
||||
// reachable through an own-pod-scoped read.
|
||||
if (userId !== currentUserId || !webId) return [];
|
||||
const joinedEventIds = new Set<string>();
|
||||
for (const p of participations) {
|
||||
if (p.userId !== webId) continue;
|
||||
const mp = derived.meetingPoints.find(m => m.id === p.eventId);
|
||||
if (mp) joinedEventIds.add(mp.eventId);
|
||||
}
|
||||
return derived.events.filter(e => joinedEventIds.has(e.id));
|
||||
}, [currentUserId, webId, participations, derived.meetingPoints, derived.events]);
|
||||
|
||||
// --- Ownership: editing is owning ------------------------------------------------
|
||||
// The list is own-pod scoped, so membership in it IS ownership. A failed
|
||||
// container read means the pod holds no event container — hence nothing
|
||||
// owned: 'not-mine', said once.
|
||||
useEffect(() => {
|
||||
if (containerFailed) {
|
||||
console.warn(`${logPrefix} events container unreadable (not provisioned, or grants missing) — treating as empty`);
|
||||
}
|
||||
}, [containerFailed, logPrefix]);
|
||||
|
||||
const getEventOwnership = useCallback((eventId: string): EventOwnership => {
|
||||
if (derived.events.some(e => e.id === eventId)) return 'mine';
|
||||
if (containerQuery.query.isPending) return 'unknown';
|
||||
return 'not-mine';
|
||||
}, [derived.events, containerQuery.query.isPending]);
|
||||
|
||||
// --- Mutations through the data provider ------------------------------------------
|
||||
const { mutateAsync: createRecord } = useCreate();
|
||||
const { mutateAsync: updateRecord } = useUpdate();
|
||||
const invalidate = useInvalidate();
|
||||
|
||||
// A join/leave is maintained asynchronously by the backend, so the immediate
|
||||
// invalidate can re-fetch BEFORE the collection is updated — and nothing
|
||||
// re-reads afterwards (the list subscription only reports membership changes).
|
||||
// ONE delayed re-invalidation per action closes that gap: no polling, at most
|
||||
// one pending timer, cancelled on unmount.
|
||||
const revalidateTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(() => () => {
|
||||
if (revalidateTimer.current) clearTimeout(revalidateTimer.current);
|
||||
revalidateTimer.current = null;
|
||||
}, []);
|
||||
const scheduleListRevalidate = useCallback(() => {
|
||||
if (revalidateTimer.current) clearTimeout(revalidateTimer.current);
|
||||
revalidateTimer.current = setTimeout(() => {
|
||||
revalidateTimer.current = null;
|
||||
void invalidate({ resource: bindings.event.resource, invalidates: ['list'] });
|
||||
}, 1500);
|
||||
}, [invalidate, bindings]);
|
||||
|
||||
const createEvent = useCallback(async (event: Omit<FpEventData, 'id'>): Promise<FpEventData> => {
|
||||
console.log(`${logPrefix} createEvent (refine):`, event.title);
|
||||
const response = await createRecord({
|
||||
resource: bindings.event.resource,
|
||||
values: bindings.event.toValues(event),
|
||||
});
|
||||
const created = bindings.event.toDomain(response.data);
|
||||
void invalidate({ resource: bindings.event.resource, invalidates: ['list'] });
|
||||
if (currentUserId) setSelectedEventId(created.id);
|
||||
return created;
|
||||
}, [logPrefix, createRecord, invalidate, bindings, currentUserId]);
|
||||
|
||||
const updateEvent = useCallback(async (id: string, updates: Partial<FpEventData>) => {
|
||||
console.log(`${logPrefix} updateEvent (refine):`, id, updates);
|
||||
await updateRecord({
|
||||
resource: bindings.event.resource,
|
||||
id,
|
||||
values: bindings.event.toPatch(updates),
|
||||
});
|
||||
void invalidate({ resource: bindings.event.resource, invalidates: ['list'] });
|
||||
}, [logPrefix, updateRecord, invalidate, bindings]);
|
||||
|
||||
const addMeetingPoint = useCallback((mp: Omit<FpMeetingPointData, 'id'>) => {
|
||||
console.log(`${logPrefix} addMeetingPoint (refine):`, mp.location);
|
||||
// The host is the creator — this session. The screen navigates away right
|
||||
// after; the optimistic mutation + invalidation lands the point on the
|
||||
// detail screen when the write returns.
|
||||
const values = bindings.meetingPoint.toValues({ ...mp, hostId: mp.hostId ?? webId });
|
||||
void createRecord({ resource: bindings.meetingPoint.resource, values })
|
||||
.then(() => invalidate({ resource: bindings.event.resource, invalidates: ['list'] }))
|
||||
.catch(err => console.error(`${logPrefix} addMeetingPoint failed:`, err));
|
||||
}, [logPrefix, createRecord, invalidate, bindings, webId]);
|
||||
|
||||
const updateProfile = useCallback(async (updates: Partial<FpUserData>) => {
|
||||
console.log(`${logPrefix} updateProfile (refine):`, updates);
|
||||
if (!profileUri) {
|
||||
const msg = 'updateProfile refused: no profile of mine is resolved (the actor document ' +
|
||||
'links none, or it is not readable) — these edits must never land in someone else\'s document.';
|
||||
console.error(`${logPrefix} ${msg}`);
|
||||
throw new Error(msg);
|
||||
}
|
||||
await updateRecord({
|
||||
resource: bindings.profile.resource,
|
||||
id: profileUri,
|
||||
values: bindings.profile.toPatch(updates),
|
||||
});
|
||||
void invalidate({ resource: bindings.profile.resource, invalidates: ['list', 'detail'] });
|
||||
}, [logPrefix, updateRecord, invalidate, bindings, profileUri]);
|
||||
|
||||
// --- Join / leave through the outbox, beside Refine --------------------------------
|
||||
const joinMeetingPoint = useCallback(async (meetingPointId: string, userId?: string) => {
|
||||
if (userId && userId !== webId) {
|
||||
// Memberships are the user's own acts; the optional userId exists for
|
||||
// parity with the other providers (and their harnesses).
|
||||
console.warn(`${logPrefix} joinMeetingPoint for another user (${userId}) is not supported on this backend — ignored`);
|
||||
return;
|
||||
}
|
||||
const mp = derived.meetingPoints.find(m => m.id === meetingPointId);
|
||||
if (!mp || !session) {
|
||||
throw new Error(`joinMeetingPoint refused: ${meetingPointId} is not in the read set ` +
|
||||
'(or there is no session) — the write would target a reference this session cannot see.');
|
||||
}
|
||||
console.log(`${logPrefix} joinMeetingPoint (refine):`, meetingPointId);
|
||||
await postJoinActivity({ meetingPointUri: mp.id, hostUri: mp.hostId, session });
|
||||
setOverlay(prev => new Map(prev).set(mp.id, 'join'));
|
||||
void invalidate({ resource: bindings.event.resource, invalidates: ['list'] });
|
||||
scheduleListRevalidate();
|
||||
}, [logPrefix, derived.meetingPoints, session, webId, invalidate, bindings, scheduleListRevalidate]);
|
||||
|
||||
const leaveMeetingPoint = useCallback(async (meetingPointId: string, userId?: string) => {
|
||||
if (userId && userId !== webId) {
|
||||
console.warn(`${logPrefix} leaveMeetingPoint for another user (${userId}) is not supported on this backend — ignored`);
|
||||
return;
|
||||
}
|
||||
const mp = derived.meetingPoints.find(m => m.id === meetingPointId);
|
||||
if (!mp || !session) {
|
||||
throw new Error(`leaveMeetingPoint refused: ${meetingPointId} is not in the read set ` +
|
||||
'(or there is no session).');
|
||||
}
|
||||
const member = participations.find(p => p.eventId === mp.id && p.userId === webId);
|
||||
if (!member) {
|
||||
const msg = `leaveMeetingPoint refused: no membership of mine on ${mp.id} — nothing was withdrawn.`;
|
||||
console.error(`${logPrefix} ${msg}`);
|
||||
throw new Error(msg);
|
||||
}
|
||||
console.log(`${logPrefix} leaveMeetingPoint (refine):`, meetingPointId);
|
||||
await postLeaveActivity({ meetingPointUri: mp.id, hostUri: mp.hostId, session });
|
||||
setOverlay(prev => new Map(prev).set(mp.id, 'leave'));
|
||||
void invalidate({ resource: bindings.event.resource, invalidates: ['list'] });
|
||||
scheduleListRevalidate();
|
||||
}, [logPrefix, derived.meetingPoints, participations, session, webId, invalidate, bindings, scheduleListRevalidate]);
|
||||
|
||||
// --- The rest of the contract -------------------------------------------------------
|
||||
const addFriend = useCallback((friendId: string) => {
|
||||
// Friendship has no persistence on this backend (the seam declares four
|
||||
// entities; friendship is deliberately not one of them). The contacts
|
||||
// concept lives on the platform — wiring it is a follow-up, not a local
|
||||
// lie.
|
||||
console.log(`${logPrefix} addFriend (refine, no-op — friendship is not persisted on this backend):`, friendId);
|
||||
}, [logPrefix]);
|
||||
|
||||
const loadTestData = useCallback(async (): Promise<BootstrapResult> => {
|
||||
// Seeding fixtures into a Pod is meaningless on this backend (and the
|
||||
// fixture seed is switched off outright) — the honest answer is "nothing
|
||||
// was seeded".
|
||||
console.log(`${logPrefix} loadTestData (refine, no-op)`);
|
||||
return emptyBootstrap();
|
||||
}, [logPrefix]);
|
||||
|
||||
const selectedEvent = derived.events.find(e => e.id === selectedEventId);
|
||||
const selectedUser = users.find(u => u.id === selectedUserId);
|
||||
|
||||
console.log(`${logPrefix} Render — refine/${backend.name} | events:`, derived.events.length,
|
||||
'| meetingPoints:', derived.meetingPoints.length, '| participations:', participations.length,
|
||||
'| selectedEvent:', selectedEvent?.title ?? '(none)');
|
||||
|
||||
return {
|
||||
currentUserId, currentUser, currentPrincipal,
|
||||
events: derived.events,
|
||||
users,
|
||||
participations,
|
||||
meetingPoints: derived.meetingPoints,
|
||||
friendships: [],
|
||||
notifications: [],
|
||||
selectedEventId, setSelectedEventId, selectedEvent,
|
||||
selectedUserId, setSelectedUserId, selectedUser,
|
||||
getEvent: shared.getEvent,
|
||||
getUser: shared.getUser,
|
||||
getEventParticipants,
|
||||
getUserEvents,
|
||||
isParticipating: shared.isParticipating,
|
||||
getFriends: shared.getFriends,
|
||||
getEventMeetingPoints: shared.getEventMeetingPoints,
|
||||
getEventOwnership,
|
||||
createEvent, updateEvent, joinMeetingPoint, leaveMeetingPoint,
|
||||
addMeetingPoint, addFriend, updateProfile, loadTestData,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { deriveContainer, applyMembershipOverlay } from './refineDerive';
|
||||
import { getBackend } from '../refine/providers';
|
||||
import type { FpParticipationData } from '../data/types';
|
||||
|
||||
// The real ActivityPods bindings: these derivations are only meaningful against
|
||||
// the vocabulary they map, so the tests drive them with the shipped backend.
|
||||
const bindings = getBackend('activitypods').bindings;
|
||||
|
||||
const EVENT = 'http://localhost:3000/alice/data/event-1';
|
||||
const MP = 'http://localhost:3000/alice/data/mp-1';
|
||||
const COLLECTION = 'http://localhost:3000/alice/data/as/collection/att-1';
|
||||
const ALICE = 'http://localhost:3000/alice';
|
||||
const BOB = 'http://localhost:3000/bob';
|
||||
|
||||
test('an as:Event container splits into events and meeting points by as:context', () => {
|
||||
const derived = deriveContainer(
|
||||
[
|
||||
{ id: EVENT, type: 'Event', name: 'Concert', startTime: '2026-11-01T18:00:00Z' },
|
||||
{
|
||||
id: MP,
|
||||
type: 'Event',
|
||||
name: 'Rdv entrée',
|
||||
'as:context': EVENT,
|
||||
organizer: ALICE,
|
||||
'apods:attendees': { id: COLLECTION, totalItems: 0, items: [] },
|
||||
},
|
||||
],
|
||||
bindings,
|
||||
);
|
||||
expect(derived.events.map(e => e.id)).toEqual([EVENT]);
|
||||
expect(derived.meetingPoints.map(mp => mp.id)).toEqual([MP]);
|
||||
expect(derived.meetingPoints[0]?.eventId).toBe(EVENT);
|
||||
// A meeting point's empty collection contributes nothing to the count.
|
||||
expect(derived.events[0]?.participantCount).toBe(0);
|
||||
});
|
||||
|
||||
test('a meeting point member becomes a participation keyed by the meeting point', () => {
|
||||
const derived = deriveContainer(
|
||||
[
|
||||
{
|
||||
id: MP,
|
||||
type: 'Event',
|
||||
'as:context': EVENT,
|
||||
'apods:attendees': { id: COLLECTION, totalItems: 2, items: [BOB, { id: ALICE }] },
|
||||
},
|
||||
],
|
||||
bindings,
|
||||
);
|
||||
expect(derived.participations).toEqual([
|
||||
{ id: `${MP}|${BOB}`, eventId: MP, userId: BOB, isConfirmed: true },
|
||||
{ id: `${MP}|${ALICE}`, eventId: MP, userId: ALICE, isConfirmed: true },
|
||||
]);
|
||||
expect(derived.totals.get(MP)).toBe(2);
|
||||
});
|
||||
|
||||
// The Alice/Bob rule, read side: a collection total survives even when no
|
||||
// member resolves, so the COUNT stays total while the participants list shows
|
||||
// the remainder as anonymous (the reader never sees a profile for them).
|
||||
test('the count stays the collection total when the members resolve to nobody', () => {
|
||||
const derived = deriveContainer(
|
||||
[
|
||||
{
|
||||
id: MP,
|
||||
type: 'Event',
|
||||
'as:context': EVENT,
|
||||
'apods:attendees': { id: COLLECTION, totalItems: 3 },
|
||||
},
|
||||
],
|
||||
bindings,
|
||||
);
|
||||
expect(derived.participations).toEqual([]);
|
||||
expect(derived.totals.get(MP)).toBe(3);
|
||||
});
|
||||
|
||||
test("an event's count is the sum of its meeting points' collection totals", () => {
|
||||
const mp2 = 'http://localhost:3000/alice/data/mp-2';
|
||||
const derived = deriveContainer(
|
||||
[
|
||||
{ id: EVENT, type: 'Event', name: 'Concert' },
|
||||
{ id: MP, type: 'Event', 'as:context': EVENT, 'apods:attendees': { totalItems: 2, items: [BOB] } },
|
||||
{ id: mp2, type: 'Event', 'as:context': EVENT, 'apods:attendees': { totalItems: 1, items: [] } },
|
||||
],
|
||||
bindings,
|
||||
);
|
||||
expect(derived.events[0]?.participantCount).toBe(3);
|
||||
});
|
||||
|
||||
test('an unreadable collection reads as an honest zero, never a guess', () => {
|
||||
const derived = deriveContainer(
|
||||
[
|
||||
{ id: EVENT, type: 'Event', name: 'Concert' },
|
||||
// The collection URI travels alone (no embed): nothing readable, no count.
|
||||
{ id: MP, type: 'Event', 'as:context': EVENT, 'apods:attendees': COLLECTION },
|
||||
],
|
||||
bindings,
|
||||
);
|
||||
expect(derived.totals.get(MP)).toBe(0);
|
||||
expect(derived.events[0]?.participantCount).toBe(0);
|
||||
});
|
||||
|
||||
test('the overlay adds a join for the session and reconciles nothing by itself', () => {
|
||||
const read: FpParticipationData[] = [
|
||||
{ id: `${MP}|${BOB}`, eventId: MP, userId: BOB, isConfirmed: true },
|
||||
];
|
||||
const joined = applyMembershipOverlay(read, new Map([[MP, 'join']]), ALICE);
|
||||
expect(joined.map(p => p.userId).sort()).toEqual([ALICE, BOB]);
|
||||
// Joining twice is idempotent: the read already carries the membership.
|
||||
const again = applyMembershipOverlay(joined, new Map([[MP, 'join']]), ALICE);
|
||||
expect(again.filter(p => p.userId === ALICE)).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('the leave overlay removes only the session membership', () => {
|
||||
const read: FpParticipationData[] = [
|
||||
{ id: `${MP}|${BOB}`, eventId: MP, userId: BOB, isConfirmed: true },
|
||||
{ id: `${MP}|${ALICE}`, eventId: MP, userId: ALICE, isConfirmed: true },
|
||||
];
|
||||
const left = applyMembershipOverlay(read, new Map([[MP, 'leave']]), ALICE);
|
||||
expect(left.map(p => p.userId)).toEqual([BOB]);
|
||||
});
|
||||
|
||||
test('an empty overlay or no session leaves the read untouched', () => {
|
||||
const read: FpParticipationData[] = [
|
||||
{ id: `${MP}|${BOB}`, eventId: MP, userId: BOB, isConfirmed: true },
|
||||
];
|
||||
expect(applyMembershipOverlay(read, new Map(), ALICE)).toEqual(read);
|
||||
expect(applyMembershipOverlay(read, new Map([[MP, 'join']]), undefined)).toEqual(read);
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* The pure read-side derivations of the Refine data path, lifted out of the
|
||||
* `useRefineData` hook so the mapping can be exercised without a React tree.
|
||||
*
|
||||
* Nothing here touches React, Refine or a backend: it is the vocabulary the
|
||||
* bindings carry, applied to a container's raw records. Two derivations live
|
||||
* here, and they are the two places the ActivityPods model meets the app's:
|
||||
*
|
||||
* 1. `deriveContainer` SPLITS one `as/Event` container into events and meeting
|
||||
* points and reads each meeting point's `apods:attendees` collection. Events
|
||||
* and meeting points share the container (the bindings' `isInstance` is the
|
||||
* only distinction), so the split has to happen exactly once, at read time.
|
||||
* The event's `participantCount` is the SUM of its meeting points' collection
|
||||
* totals — never a stored, incremented counter, and never the number of
|
||||
* RESOLVED profiles, so it stays total for a reader who may resolve nobody
|
||||
* (the Alice/Bob rule).
|
||||
*
|
||||
* 2. `applyMembershipOverlay` reflects a join/leave made beside Refine (through
|
||||
* the outbox) before the collection read carries it. It reconciles away on
|
||||
* its own once the read shows the truth — no polling.
|
||||
*/
|
||||
|
||||
import type { FpEventData, FpMeetingPointData, FpParticipationData } from '../data/types';
|
||||
import type { RecordBindings } from '../refine/providers/types';
|
||||
|
||||
/** What one container read yields, before selection and overlays. */
|
||||
export interface DerivedContainer {
|
||||
events: FpEventData[];
|
||||
meetingPoints: FpMeetingPointData[];
|
||||
/**
|
||||
* Participations keyed BY MEETING POINT: `eventId` carries the meeting point
|
||||
* id and `userId` the attendee reference — which may be a URI no profile
|
||||
* resolves to (an anonymous participant).
|
||||
*/
|
||||
participations: FpParticipationData[];
|
||||
/** Per-meeting-point collection totals, keyed by meeting point id. */
|
||||
totals: Map<string, number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a container's raw records with the backend's own bindings and derive
|
||||
* the participations and the participant counts.
|
||||
*
|
||||
* A meeting point's attendees collection travels inside its record; each member
|
||||
* is mapped to a participation stamped with the HOST meeting point's id (a
|
||||
* member carries no link back to what it attends), and the collection's total is
|
||||
* kept as-is — a member the reader cannot resolve still counts.
|
||||
*/
|
||||
export function deriveContainer(
|
||||
records: readonly unknown[],
|
||||
bindings: RecordBindings,
|
||||
): DerivedContainer {
|
||||
const events: FpEventData[] = [];
|
||||
const meetingPoints: FpMeetingPointData[] = [];
|
||||
const participations: FpParticipationData[] = [];
|
||||
const totals = new Map<string, number>();
|
||||
for (const record of records) {
|
||||
if (bindings.meetingPoint.isInstance?.(record) ?? false) {
|
||||
const mp = bindings.meetingPoint.toDomain(record);
|
||||
meetingPoints.push(mp);
|
||||
const attendees = bindings.participation.membersOf?.(record);
|
||||
const members = attendees?.members ?? [];
|
||||
for (const member of members) {
|
||||
const mapped = bindings.participation.toDomain(member);
|
||||
participations.push({ ...mapped, id: `${mp.id}|${mapped.userId}`, eventId: mp.id });
|
||||
}
|
||||
totals.set(mp.id, attendees?.total ?? members.length);
|
||||
} else if (bindings.event.isInstance?.(record) ?? true) {
|
||||
events.push(bindings.event.toDomain(record));
|
||||
}
|
||||
}
|
||||
// The event's count is the SUM of its meeting points' collection totals —
|
||||
// derived at read time, never a stored counter. A record whose collection
|
||||
// could not be read contributes 0, which is the honest "unknown" reading of
|
||||
// a total nobody could count for us.
|
||||
for (const event of events) {
|
||||
event.participantCount = meetingPoints
|
||||
.filter(mp => mp.eventId === event.id)
|
||||
.reduce((sum, mp) => sum + (totals.get(mp.id) ?? 0), 0);
|
||||
}
|
||||
return { events, meetingPoints, participations, totals };
|
||||
}
|
||||
|
||||
/** A membership overlay entry: what this session just joined or left. */
|
||||
export type MembershipOverlay = Map<string, 'join' | 'leave'>;
|
||||
|
||||
/**
|
||||
* Apply the optimistic join/leave overlay on top of the read participations.
|
||||
*
|
||||
* A `join` adds a membership for `webId` on the meeting point unless the read
|
||||
* already carries it; a `leave` removes it. With no overlay entry, or no
|
||||
* `webId` to attribute the act to, the read is returned untouched.
|
||||
*/
|
||||
export function applyMembershipOverlay(
|
||||
participations: readonly FpParticipationData[],
|
||||
overlay: MembershipOverlay,
|
||||
webId: string | undefined,
|
||||
): FpParticipationData[] {
|
||||
if (overlay.size === 0 || !webId) return [...participations];
|
||||
const byMp = new Map<string, FpParticipationData[]>();
|
||||
for (const p of participations) {
|
||||
const list = byMp.get(p.eventId) ?? [];
|
||||
list.push(p);
|
||||
byMp.set(p.eventId, list);
|
||||
}
|
||||
for (const [mpId, op] of overlay) {
|
||||
const current = byMp.get(mpId) ?? [];
|
||||
if (op === 'join') {
|
||||
if (!current.some(p => p.userId === webId)) {
|
||||
byMp.set(mpId, [...current, { id: `${mpId}|${webId}`, eventId: mpId, userId: webId, isConfirmed: true }]);
|
||||
}
|
||||
} else {
|
||||
byMp.set(mpId, current.filter(p => p.userId !== webId));
|
||||
}
|
||||
}
|
||||
return [...byMp.values()].flat();
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { fetchActorDocument, fetchActorProfileRecord } from './actor';
|
||||
|
||||
function installFetch(handler: (url: string, init: RequestInit) => Response) {
|
||||
const calls: Array<{ url: string; init: RequestInit }> = [];
|
||||
const original = globalThis.fetch;
|
||||
globalThis.fetch = ((url: string | URL | Request, init?: RequestInit) => {
|
||||
const urlString = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
|
||||
const requestInit = init ?? {};
|
||||
calls.push({ url: urlString, init: requestInit });
|
||||
return Promise.resolve(handler(urlString, requestInit));
|
||||
}) as typeof fetch;
|
||||
return {
|
||||
calls,
|
||||
restore() {
|
||||
globalThis.fetch = original;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/ld+json' },
|
||||
});
|
||||
}
|
||||
|
||||
const ACTOR = 'http://localhost:3000/alice';
|
||||
const PROFILE = 'http://localhost:3000/alice/as/profile';
|
||||
|
||||
test('fetchActorDocument extracts the outbox and the profile link, compact or prefixed', async () => {
|
||||
const double = installFetch((url) => {
|
||||
if (url === ACTOR) {
|
||||
return jsonResponse({ id: ACTOR, outbox: 'http://localhost:3000/alice/outbox', url: PROFILE });
|
||||
}
|
||||
return jsonResponse({}, 404);
|
||||
});
|
||||
try {
|
||||
const actor = await fetchActorDocument(ACTOR, 'tok-1');
|
||||
expect(actor).toEqual({ outbox: 'http://localhost:3000/alice/outbox', profileUri: PROFILE });
|
||||
expect((double.calls[0]!.init.headers as Record<string, string>)['Authorization']).toBe('Bearer tok-1');
|
||||
} finally {
|
||||
double.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('fetchActorDocument reads the as:-prefixed outbox form and tolerates a missing link', async () => {
|
||||
const double = installFetch(() => jsonResponse({ '@id': ACTOR, 'as:outbox': { id: 'http://localhost:3000/alice/outbox' } }));
|
||||
try {
|
||||
const actor = await fetchActorDocument(ACTOR);
|
||||
expect(actor.outbox).toBe('http://localhost:3000/alice/outbox');
|
||||
expect(actor.profileUri).toBeUndefined();
|
||||
} finally {
|
||||
double.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('fetchActorDocument throws on a non-2xx actor read — it is a probe, not the absorbing path', async () => {
|
||||
const double = installFetch(() => jsonResponse({}, 500));
|
||||
try {
|
||||
await expect(fetchActorDocument(ACTOR)).rejects.toThrow(/HTTP 500/);
|
||||
} finally {
|
||||
double.restore();
|
||||
}
|
||||
});
|
||||
|
||||
// THE ALICE/BOB RULE: every failure mode of the profile read — a WAC refusal on
|
||||
// the profile, a missing actor link, an unreadable actor — reads back as
|
||||
// undefined, never throws. The non-contact answer IS the refusal.
|
||||
test('fetchActorProfileRecord absorbs the WAC refusal and the missing-link cases', async () => {
|
||||
const refusing = installFetch((url) => {
|
||||
if (url === ACTOR) return jsonResponse({ id: ACTOR, url: PROFILE });
|
||||
return jsonResponse({ error: 'Forbidden' }, 403);
|
||||
});
|
||||
try {
|
||||
expect(await fetchActorProfileRecord(ACTOR, 'tok-1')).toBeUndefined();
|
||||
} finally {
|
||||
refusing.restore();
|
||||
}
|
||||
|
||||
const noLink = installFetch(() => jsonResponse({ id: ACTOR }));
|
||||
try {
|
||||
expect(await fetchActorProfileRecord(ACTOR, 'tok-1')).toBeUndefined();
|
||||
} finally {
|
||||
noLink.restore();
|
||||
}
|
||||
|
||||
const actorFails = installFetch(() => jsonResponse({}, 404));
|
||||
try {
|
||||
expect(await fetchActorProfileRecord(ACTOR, 'tok-1')).toBeUndefined();
|
||||
} finally {
|
||||
actorFails.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('fetchActorProfileRecord returns the raw profile record on success', async () => {
|
||||
const record = { id: PROFILE, type: 'Profile', name: 'Alice' };
|
||||
const double = installFetch((url) => {
|
||||
if (url === ACTOR) return jsonResponse({ id: ACTOR, url: PROFILE });
|
||||
return jsonResponse(record);
|
||||
});
|
||||
try {
|
||||
expect(await fetchActorProfileRecord(ACTOR, 'tok-1')).toEqual(record);
|
||||
} finally {
|
||||
double.restore();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Actor document and profile reads — the small, direct-HTTP side of the
|
||||
* ActivityPods social model, living BESIDE Refine because the refine-providers
|
||||
* package implements no `custom` method and these reads are not CRUD over the
|
||||
* app's own containers.
|
||||
*
|
||||
* WHAT AN ACTOR DOCUMENT IS. The WebID document IS the ActivityPub actor: it
|
||||
* carries the `outbox` (where `as:Join`/`as:Leave` are posted) and `url`
|
||||
* (linking to the user's `as:Profile` record, the one readable display-name
|
||||
* source). Neither is exposed by the auth provider's session or identity.
|
||||
*
|
||||
* THE ALICE/BOB RULE IS ABSORBED HERE. Resolving an attendee to a profile is
|
||||
* two fetches (actor → `url` → profile), and the second is WAC-gated: only a
|
||||
* contact may read it. A refusal, a missing link or a malformed document all
|
||||
* read back as `undefined` — the caller renders a generic participant and the
|
||||
* TOTAL stays the count of the collection, never of the resolved. A failure is
|
||||
* not exceptional in this model; it IS the non-contact answer.
|
||||
*/
|
||||
|
||||
import { asRecord, linkId } from './jsonRecord';
|
||||
|
||||
/** The parts of the actor (WebID) document this app uses. */
|
||||
export interface ActorDocument {
|
||||
/** The actor's outbox URI — where activities are posted. */
|
||||
outbox?: string;
|
||||
/** URI of the actor's `as:Profile` record (the `url` predicate). */
|
||||
profileUri?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the actor document and extract what the app uses. The WebID document
|
||||
* is public on the platform (verified: an anonymous read is not refused); the
|
||||
* token is still sent — the reference integration does the same, and a pod
|
||||
* whose WebID is not public answers whatever its ACL says. A network or
|
||||
* non-2xx failure THROWS: this fetch is the app's own probe, not the
|
||||
* refusal-absorbing path.
|
||||
*/
|
||||
export async function fetchActorDocument(webId: string, token?: string): Promise<ActorDocument> {
|
||||
const response = await fetch(webId, {
|
||||
headers: {
|
||||
Accept: 'application/ld+json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`actor document fetch failed: HTTP ${response.status} for ${webId}`);
|
||||
}
|
||||
const body: unknown = await response.json();
|
||||
const record = asRecord(body);
|
||||
return {
|
||||
outbox: linkId(record?.['outbox']) ?? linkId(record?.['as:outbox']),
|
||||
profileUri: linkId(record?.['url']),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an actor (WebID) URI to its profile RECORD, or `undefined` when the
|
||||
* profile cannot be read. Two fetches: the actor document, then the profile it
|
||||
* links. Every failure mode — 403 (the Alice/Bob non-contact refusal), 404, a
|
||||
* missing `url`, a non-object body — collapses to `undefined`; the record is
|
||||
* returned raw for the binding's `toDomain` to map, so this module stays free
|
||||
* of vocabulary.
|
||||
*/
|
||||
export async function fetchActorProfileRecord(webId: string, token?: string): Promise<unknown> {
|
||||
try {
|
||||
const actor = await fetchActorDocument(webId, token);
|
||||
if (!actor.profileUri) return undefined;
|
||||
const response = await fetch(actor.profileUri, {
|
||||
headers: {
|
||||
Accept: 'application/ld+json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
});
|
||||
if (!response.ok) return undefined;
|
||||
const body: unknown = await response.json();
|
||||
return asRecord(body) ?? undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
+162
-24
File diff suppressed because one or more lines are too long
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Typed readers for untyped JSON-LD values.
|
||||
*
|
||||
* Pod documents arrive as plain parsed JSON whose predicates are verbatim but
|
||||
* whose values are untyped. These helpers narrow them without ever naming
|
||||
* `any`, so callers stay honest about what a record MAY carry. The same
|
||||
* vocabulary-free narrowing exists inside the ActivityPods binding
|
||||
* (`refine/providers/activitypods.ts`); both copies are deliberate — the
|
||||
* binding never exports its internals, and the data context must not import one
|
||||
* backend module to serve another.
|
||||
*/
|
||||
|
||||
export type JsonRecord = Record<string, unknown>;
|
||||
|
||||
/** The value when it is a plain object (not null, not an array). */
|
||||
export function asRecord(value: unknown): JsonRecord | undefined {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
? (value as JsonRecord)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/** The value when it is a string. */
|
||||
export function asString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' ? value : undefined;
|
||||
}
|
||||
|
||||
/** First string of a predicate that may hold a single value or an array of them. */
|
||||
export function firstString(value: unknown): string | undefined {
|
||||
return asString(value) ?? (Array.isArray(value) ? asString(value[0]) : undefined);
|
||||
}
|
||||
|
||||
/** A linked value in an ActivityStreams record: a bare IRI string, or an object carrying id/@id. */
|
||||
export function linkId(value: unknown): string | undefined {
|
||||
const direct = asString(value);
|
||||
if (direct) return direct;
|
||||
const object = asRecord(value);
|
||||
return object ? (asString(object['id']) ?? asString(object['@id'])) : undefined;
|
||||
}
|
||||
|
||||
/** First entry of a predicate that may hold a single value or an array of them. */
|
||||
export function firstEntry(value: unknown): unknown {
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { expect, test, afterEach } from 'bun:test';
|
||||
import { postJoinActivity, postLeaveActivity } from './outbox';
|
||||
import type { OutboxActivityInput } from './outbox';
|
||||
|
||||
const ACTOR = 'http://localhost:3000/alice';
|
||||
const OUTBOX = 'http://localhost:3000/alice/outbox';
|
||||
const MEETING_POINT = 'http://localhost:3000/alice/data/as/event/mp-1';
|
||||
|
||||
const input = (hostUri: string | undefined): OutboxActivityInput => ({
|
||||
meetingPointUri: MEETING_POINT,
|
||||
hostUri,
|
||||
session: { webId: ACTOR, token: 'tok-1' },
|
||||
});
|
||||
|
||||
/** Installs a scripted fetch double, returning the calls it saw. */
|
||||
function installFetch(handler: (url: string, init: RequestInit) => Response) {
|
||||
const calls: Array<{ url: string; init: RequestInit }> = [];
|
||||
const original = globalThis.fetch;
|
||||
globalThis.fetch = ((url: string | URL | Request, init?: RequestInit) => {
|
||||
const urlString = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
|
||||
const requestInit = init ?? {};
|
||||
calls.push({ url: urlString, init: requestInit });
|
||||
return Promise.resolve(handler(urlString, requestInit));
|
||||
}) as typeof fetch;
|
||||
return {
|
||||
calls,
|
||||
restore() {
|
||||
globalThis.fetch = original;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/ld+json' },
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
// Each test installs its own double; nothing to do here beyond documenting
|
||||
// that fetch is always restored by the per-test handle.
|
||||
});
|
||||
|
||||
test('postJoinActivity discovers the outbox from the actor document and posts a Join to it', async () => {
|
||||
const double = installFetch((url, init) => {
|
||||
if (url === ACTOR) return jsonResponse({ id: ACTOR, outbox: OUTBOX, url: `${ACTOR}/as/profile` });
|
||||
return jsonResponse({}, 201);
|
||||
});
|
||||
try {
|
||||
await postJoinActivity(input('http://localhost:3000/bob'));
|
||||
expect(double.calls).toHaveLength(2);
|
||||
const [actorCall, postCall] = double.calls;
|
||||
expect(actorCall).toBeDefined();
|
||||
expect(postCall).toBeDefined();
|
||||
expect(actorCall!.url).toBe(ACTOR);
|
||||
expect(postCall!.url).toBe(OUTBOX);
|
||||
expect(postCall!.init.method).toBe('POST');
|
||||
const headers = postCall!.init.headers as Record<string, string>;
|
||||
expect(headers['Authorization']).toBe('Bearer tok-1');
|
||||
expect(headers['Content-Type']).toBe('application/ld+json');
|
||||
const body = JSON.parse(String(postCall!.init.body));
|
||||
expect(body).toEqual({
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
type: 'Join',
|
||||
actor: ACTOR,
|
||||
object: MEETING_POINT,
|
||||
to: 'http://localhost:3000/bob',
|
||||
});
|
||||
} finally {
|
||||
double.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('postLeaveActivity posts a Leave addressed to the host', async () => {
|
||||
const double = installFetch((url) => {
|
||||
if (url === ACTOR) return jsonResponse({ id: ACTOR, outbox: OUTBOX });
|
||||
return jsonResponse({}, 201);
|
||||
});
|
||||
try {
|
||||
await postLeaveActivity(input('http://localhost:3000/bob'));
|
||||
const postCall = double.calls[1]!;
|
||||
const body = JSON.parse(String(postCall.init.body));
|
||||
expect(body.type).toBe('Leave');
|
||||
expect(body.to).toBe('http://localhost:3000/bob');
|
||||
} finally {
|
||||
double.restore();
|
||||
}
|
||||
});
|
||||
|
||||
// A Join/Leave with no addressee is silently dropped by the backend, so posting
|
||||
// one is refused loudly BEFORE any network call is made.
|
||||
test('a missing host refuses the post before any network call', async () => {
|
||||
const double = installFetch(() => jsonResponse({ id: ACTOR, outbox: OUTBOX }));
|
||||
try {
|
||||
await expect(postJoinActivity(input(undefined))).rejects.toThrow(/host is unknown|no addressee/i);
|
||||
await expect(postLeaveActivity(input(undefined))).rejects.toThrow(/host is unknown|no addressee/i);
|
||||
expect(double.calls).toHaveLength(0);
|
||||
} finally {
|
||||
double.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('an actor document without an outbox refuses the post, naming the activity', async () => {
|
||||
const double = installFetch(() => jsonResponse({ id: ACTOR }));
|
||||
try {
|
||||
await expect(postJoinActivity(input('http://localhost:3000/bob'))).rejects.toThrow(/carries no outbox.*Join/s);
|
||||
expect(double.calls).toHaveLength(1);
|
||||
} finally {
|
||||
double.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('a non-2xx outbox response throws with the status', async () => {
|
||||
const double = installFetch((url) => {
|
||||
if (url === ACTOR) return jsonResponse({ id: ACTOR, outbox: OUTBOX });
|
||||
return jsonResponse({ error: 'Forbidden' }, 403);
|
||||
});
|
||||
try {
|
||||
await expect(postLeaveActivity(input('http://localhost:3000/bob'))).rejects.toThrow(/HTTP 403/);
|
||||
} finally {
|
||||
double.restore();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Outbox posting — how a user joins or leaves a meeting point.
|
||||
*
|
||||
* WHY THIS LIVES BESIDE REFINE. The refine-providers package implements no
|
||||
* `custom` method: there is no escape hatch through the data provider, and a
|
||||
* participation is not a CRUD write anyway. The platform's production
|
||||
* precedent (recorded in the data-layer brief, reconnaissance of 2026-10-09)
|
||||
* is: a join is an `as:Join` activity posted to the USER'S OWN outbox with the
|
||||
* user's token; the on-behalf backend watches the flow and maintains the
|
||||
* meeting point's `apods:attendees` collection; membership IS the attendance —
|
||||
* a leave is `as:Leave` on the same path, and an absence from the collection
|
||||
* reads as not attending.
|
||||
*
|
||||
* THE FLOW. The outbox URI is read off the user's own actor (WebID) document
|
||||
* (`outbox` predicate) at post time — the reference integration resolves it
|
||||
* the same way, and posting before the actor document has been read is what it
|
||||
* guards against. The activity's `object` is the meeting point's URI and its
|
||||
* `to` is the meeting point's host (the record's Pod-stamped `dc:creator`), so
|
||||
* the host's Pod receives it. THE HOST IS REQUIRED: a Join/Leave with no
|
||||
* addressee is silently dropped by the backend, so posting without a host URI
|
||||
* throws rather than constructing an activity nothing will ever receive.
|
||||
*
|
||||
* ASYNCHRONY IS THE PLATFORM'S. The collection update lands when the backend
|
||||
* has processed the activity — the caller must not treat the 2xx of the post
|
||||
* as the membership. The data layer carries an optimistic overlay for exactly
|
||||
* that gap.
|
||||
*/
|
||||
|
||||
import type { CurrentSession } from '../utils/currentSession';
|
||||
import { fetchActorDocument } from './actor';
|
||||
|
||||
/** The vocabulary context the posted activity carries. */
|
||||
const ACTIVITY_CONTEXT = 'https://www.w3.org/ns/activitystreams';
|
||||
|
||||
export interface OutboxActivityInput {
|
||||
/** URI of the meeting point the activity targets. */
|
||||
meetingPointUri: string;
|
||||
/** The meeting point's host (the record's `dc:creator`) — where the activity is addressed. */
|
||||
hostUri: string | undefined;
|
||||
/** The authenticated session — whose outbox receives the activity. */
|
||||
session: CurrentSession;
|
||||
}
|
||||
|
||||
async function postActivity(type: 'Join' | 'Leave', input: OutboxActivityInput): Promise<void> {
|
||||
const { meetingPointUri, hostUri, session } = input;
|
||||
// A Join/Leave is addressed to the host: without a `to` the backend never
|
||||
// routes it, so the attendee would silently never be recorded. Fail loudly
|
||||
// before posting rather than construct an activity nothing receives.
|
||||
if (!hostUri) {
|
||||
throw new Error(
|
||||
`cannot post ${type} for ${meetingPointUri}: the meeting point's host is unknown ` +
|
||||
'(no addressee) — the activity would never be delivered.',
|
||||
);
|
||||
}
|
||||
const actor = await fetchActorDocument(session.webId, session.token);
|
||||
if (!actor.outbox) {
|
||||
throw new Error(`actor document for ${session.webId} carries no outbox — cannot post ${type}`);
|
||||
}
|
||||
const activity: Record<string, unknown> = {
|
||||
'@context': ACTIVITY_CONTEXT,
|
||||
type,
|
||||
actor: session.webId,
|
||||
object: meetingPointUri,
|
||||
to: hostUri,
|
||||
};
|
||||
|
||||
const response = await fetch(actor.outbox, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/ld+json',
|
||||
Accept: 'application/ld+json',
|
||||
Authorization: `Bearer ${session.token}`,
|
||||
},
|
||||
body: JSON.stringify(activity),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`outbox ${type} post failed: HTTP ${response.status} for ${actor.outbox}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Post an `as:Join` activity to the user's outbox — signing up to a meeting point. */
|
||||
export function postJoinActivity(input: OutboxActivityInput): Promise<void> {
|
||||
return postActivity('Join', input);
|
||||
}
|
||||
|
||||
/** Post an `as:Leave` activity to the user's outbox — leaving a meeting point. */
|
||||
export function postLeaveActivity(input: OutboxActivityInput): Promise<void> {
|
||||
return postActivity('Leave', input);
|
||||
}
|
||||
@@ -430,6 +430,62 @@ export const stepDefinitions: StepDefinitionInfo[] = [
|
||||
"sourceCode": "Then('le compte autoritatif de participation de A à l\\'événement {string} est {int}', { timeout: 60000 }, async function (this: FestipodWorld, _title: string, expected: number) {\n const freshFrame = (this as any).recoFreshFrame;\n const eventId = (this as any).isoEventId;\n await freshFrame.evaluate(async () => { await (window as any).__testData.ensureCurrentUser(); });",
|
||||
"lineNumber": 165
|
||||
},
|
||||
{
|
||||
"pattern": "le pod provider local répond sur son endpoint OpenID",
|
||||
"keyword": "Then",
|
||||
"file": "activitypods.steps.ts",
|
||||
"sourceCode": "Then(\"le pod provider local répond sur son endpoint OpenID\", async function (this: FestipodWorld) {\n const { podProviderUrl } = apodsStackConfig();\n const status = await urlStatus(`${podProviderUrl}/.well-known/openid-configuration`);\n expect(status, `GET ${podProviderUrl}/.well-known/openid-configuration`).to.equal(200);\n});",
|
||||
"lineNumber": 40
|
||||
},
|
||||
{
|
||||
"pattern": "le backend Festipod sert la description de son application",
|
||||
"keyword": "Then",
|
||||
"file": "activitypods.steps.ts",
|
||||
"sourceCode": "Then(\"le backend Festipod sert la description de son application\", async function (this: FestipodWorld) {\n const { backendUrl } = apodsStackConfig();\n const description = await readResource(undefined, `${backendUrl}/app`);\n // The description IS the OIDC client the app registers with: it must carry\n // the interop access-need group the Pod grants, not merely answer 200.\n expect(JSON.stringify(description.type)).to.contain('Application');\n expect(linkId(description['interop:hasAccessNeedGroup']), 'access-need group').to.be.a('string');\n});",
|
||||
"lineNumber": 46
|
||||
},
|
||||
{
|
||||
"pattern": "l'application Festipod est chargée depuis la pile locale",
|
||||
"keyword": "When",
|
||||
"file": "activitypods.steps.ts",
|
||||
"sourceCode": "When(\"l'application Festipod est chargée depuis la pile locale\", async function (this: FestipodWorld) {\n const appUrl = requires(this, this.apods.appUrl, 'app URL (set by the @browser Before hook)');\n expect(this.page, 'a Playwright page (set by the @browser Before hook)').to.not.be.null;\n await this.page!.goto(appUrl, { waitUntil: 'domcontentloaded' });",
|
||||
"lineNumber": 57
|
||||
},
|
||||
{
|
||||
"pattern": "l'entrée de connexion au Pod est proposée",
|
||||
"keyword": "Then",
|
||||
"file": "activitypods.steps.ts",
|
||||
"sourceCode": "Then(\"l'entrée de connexion au Pod est proposée\", async function (this: FestipodWorld) {\n const { podProviderUrl } = apodsStackConfig();\n const host = new URL(podProviderUrl).host;\n await this.page!.waitForSelector(`text=Se connecter avec ${host}`, { timeout: 30000 });",
|
||||
"lineNumber": 65
|
||||
},
|
||||
{
|
||||
"pattern": "aucune erreur runtime n'a été émise au chargement",
|
||||
"keyword": "Then",
|
||||
"file": "activitypods.steps.ts",
|
||||
"sourceCode": "Then(\"aucune erreur runtime n'a été émise au chargement\", function (this: FestipodWorld) {\n expect(\n this.pageErrors,\n `Des erreurs runtime ont été émises au chargement de l'app :\\n` +\n this.pageErrors.map((e, i) => ` [${i + 1}] ${e}`).join('\\n'),\n ).to.be.empty;\n});",
|
||||
"lineNumber": 71
|
||||
},
|
||||
{
|
||||
"pattern": "la session rejoint le point de rencontre",
|
||||
"keyword": "When",
|
||||
"file": "activitypods.steps.ts",
|
||||
"sourceCode": "When('la session rejoint le point de rencontre', async function (this: FestipodWorld) {\n const pod = session(this);\n const mpUri = requires(this, this.apods.meetingPointUri, 'meeting point URI');\n // The app's own path (data/outbox.ts): as:Join to the user's outbox, carrying\n // the meeting point record so the backend finds its apods:attendees collection.\n await postOutboxActivity(pod, 'Join', await readResource(pod, mpUri), pod.webId);\n});",
|
||||
"lineNumber": 158
|
||||
},
|
||||
{
|
||||
"pattern": "la session quitte le point de rencontre",
|
||||
"keyword": "When",
|
||||
"file": "activitypods.steps.ts",
|
||||
"sourceCode": "When('la session quitte le point de rencontre', async function (this: FestipodWorld) {\n const pod = session(this);\n const mpUri = requires(this, this.apods.meetingPointUri, 'meeting point URI');\n await postOutboxActivity(pod, 'Leave', await readResource(pod, mpUri), pod.webId);\n});",
|
||||
"lineNumber": 166
|
||||
},
|
||||
{
|
||||
"pattern": "la collection apods:attendees contient la session",
|
||||
"keyword": "Then",
|
||||
"file": "activitypods.steps.ts",
|
||||
"sourceCode": "Then('la collection apods:attendees contient la session', async function (this: FestipodWorld) {\n const pod = session(this);\n const collectionUri = requires(this, this.apods.collectionUri, 'attendees collection URI');\n const members = await readAttendees(pod, collectionUri);\n expect(members.map(linkId)).to.include(pod.webId);\n});",
|
||||
"lineNumber": 172
|
||||
},
|
||||
{
|
||||
"pattern": "le portefeuille contient des données de test",
|
||||
"keyword": "Given",
|
||||
@@ -700,29 +756,29 @@ export const stepDefinitions: StepDefinitionInfo[] = [
|
||||
"pattern": "je peux m'inscrire à l'événement",
|
||||
"keyword": "Then",
|
||||
"file": "event.steps.ts",
|
||||
"sourceCode": "Then('je peux m\\'inscrire à l\\'événement', async function (this: FestipodWorld) {\n expect(this.currentScreenId).to.equal('event-detail');\n const doc = this.renderedDoc;\n expect(doc, 'Screen should be rendered').to.not.be.null;\n const buttons = Array.from(doc!.querySelectorAll('button')).map(b => b.textContent ?? '');\n const hasToggle = buttons.some(t => t.includes(\"J'y serai\") || t.includes('Je participe'));\n expect(hasToggle, 'Event detail should expose a join/leave toggle button').to.be.true;\n});",
|
||||
"lineNumber": 80
|
||||
"sourceCode": "Then('je peux m\\'inscrire à l\\'événement', async function (this: FestipodWorld) {\n expect(this.currentScreenId).to.equal('event-detail');\n const doc = this.renderedDoc;\n expect(doc, 'Screen should be rendered').to.not.be.null;\n const buttons = Array.from(doc!.querySelectorAll('button')).map(b => b.textContent ?? '');\n const hasSignup = buttons.some(t => t.includes(\"S'inscrire\") || t.includes('Inscrit'));\n expect(hasSignup, 'Event detail should expose a meeting-point signup button').to.be.true;\n});",
|
||||
"lineNumber": 83
|
||||
},
|
||||
{
|
||||
"pattern": "je peux me désinscrire de l'événement",
|
||||
"keyword": "Then",
|
||||
"file": "event.steps.ts",
|
||||
"sourceCode": "Then('je peux me désinscrire de l\\'événement', async function (this: FestipodWorld) {\n expect(this.currentScreenId).to.equal('event-detail');\n const doc = this.renderedDoc;\n expect(doc, 'Screen should be rendered').to.not.be.null;\n const buttons = Array.from(doc!.querySelectorAll('button')).map(b => b.textContent ?? '');\n const hasToggle = buttons.some(t => t.includes(\"J'y serai\") || t.includes('Je participe'));\n expect(hasToggle, 'Event detail should expose a join/leave toggle button').to.be.true;\n});",
|
||||
"lineNumber": 89
|
||||
"sourceCode": "Then('je peux me désinscrire de l\\'événement', async function (this: FestipodWorld) {\n expect(this.currentScreenId).to.equal('event-detail');\n const doc = this.renderedDoc;\n expect(doc, 'Screen should be rendered').to.not.be.null;\n const buttons = Array.from(doc!.querySelectorAll('button')).map(b => b.textContent ?? '');\n const hasSignup = buttons.some(t => t.includes(\"S'inscrire\") || t.includes('Inscrit'));\n expect(hasSignup, 'Event detail should expose a meeting-point signup toggle').to.be.true;\n});",
|
||||
"lineNumber": 92
|
||||
},
|
||||
{
|
||||
"pattern": "le formulaire contient le champ obligatoire {string}",
|
||||
"keyword": "Then",
|
||||
"file": "event.steps.ts",
|
||||
"sourceCode": "Then('le formulaire contient le champ obligatoire {string}', async function (this: FestipodWorld, fieldName: string) {\n expect(this.currentScreenId, 'This step is for form screens only').to.equal('create-event');\n expectRequiredField(this, fieldName);\n});",
|
||||
"lineNumber": 100
|
||||
"lineNumber": 103
|
||||
},
|
||||
{
|
||||
"pattern": "le formulaire contient les champs obligatoires suivants:",
|
||||
"keyword": "Then",
|
||||
"file": "event.steps.ts",
|
||||
"sourceCode": "Then('le formulaire contient les champs obligatoires suivants:', async function (this: FestipodWorld, dataTable) {\n expect(this.currentScreenId, 'This step is for form screens only').to.equal('create-event');\n const expectedFields = dataTable.raw().flat();\n expectedFields.forEach((fieldName: string) => expectRequiredField(this, fieldName));\n});",
|
||||
"lineNumber": 105
|
||||
"lineNumber": 108
|
||||
},
|
||||
{
|
||||
"pattern": "l'application connectée affiche l'accueil",
|
||||
|
||||
@@ -15,11 +15,11 @@ interface RawFeatureTestStatus {
|
||||
const rawResults: RawFeatureTestStatus[] = [
|
||||
{
|
||||
"featureId": "us-13",
|
||||
"totalScenarios": 10,
|
||||
"passed": 7,
|
||||
"totalScenarios": 7,
|
||||
"passed": 4,
|
||||
"failed": 0,
|
||||
"skipped": 3,
|
||||
"lastRun": "2026-01-26T17:31:35.878Z",
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Accéder au formulaire de relai d'événement",
|
||||
@@ -37,18 +37,6 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
"name": "Pouvoir annuler le relai d'événement",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Détecter un événement similaire déjà relayé",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Importer un événement depuis une source externe",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Pas d'alerte doublon lors d'un import externe",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Modifier un événement",
|
||||
"status": "skipped"
|
||||
@@ -69,7 +57,7 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
"passed": 3,
|
||||
"failed": 0,
|
||||
"skipped": 0,
|
||||
"lastRun": "2026-01-26T17:31:35.878Z",
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Accéder aux détails d'un événement terminé",
|
||||
@@ -91,7 +79,7 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
"passed": 0,
|
||||
"failed": 0,
|
||||
"skipped": 5,
|
||||
"lastRun": "2026-01-26T17:31:35.878Z",
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Voir les commentaires existants",
|
||||
@@ -117,35 +105,23 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
},
|
||||
{
|
||||
"featureId": "us-7",
|
||||
"totalScenarios": 6,
|
||||
"passed": 2,
|
||||
"totalScenarios": 3,
|
||||
"passed": 3,
|
||||
"failed": 0,
|
||||
"skipped": 4,
|
||||
"lastRun": "2026-01-26T17:31:35.878Z",
|
||||
"skipped": 0,
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Consulter un événement avant inscription",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "S'inscrire à un événement",
|
||||
"status": "skipped"
|
||||
},
|
||||
{
|
||||
"name": "Se désinscrire d'un événement",
|
||||
"status": "skipped"
|
||||
"name": "Voir le bouton d'inscription sur l'écran",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Rechercher un événement existant",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Vérifier les données de l'écran",
|
||||
"status": "skipped"
|
||||
},
|
||||
{
|
||||
"name": "Rechercher dans une base existante (Mobilizon)",
|
||||
"status": "skipped"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -155,7 +131,7 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
"passed": 0,
|
||||
"failed": 0,
|
||||
"skipped": 8,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Consulter un macro-événement",
|
||||
@@ -193,26 +169,22 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
},
|
||||
{
|
||||
"featureId": "us-16",
|
||||
"totalScenarios": 4,
|
||||
"passed": 4,
|
||||
"totalScenarios": 3,
|
||||
"passed": 3,
|
||||
"failed": 0,
|
||||
"skipped": 0,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Accéder aux points de rencontre",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Voir le bouton pour proposer un point de rencontre",
|
||||
"name": "Voir le formulaire de proposition",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Ouvrir le formulaire de proposition",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Définir l'heure de rencontre",
|
||||
"name": "Renseigner les détails du point de rencontre",
|
||||
"status": "passed"
|
||||
}
|
||||
]
|
||||
@@ -223,7 +195,7 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
"passed": 0,
|
||||
"failed": 0,
|
||||
"skipped": 5,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Partager un événement auquel je participe",
|
||||
@@ -253,7 +225,7 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
"passed": 1,
|
||||
"failed": 0,
|
||||
"skipped": 3,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Configurer les notifications de nouveaux participants",
|
||||
@@ -279,7 +251,7 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
"passed": 3,
|
||||
"failed": 0,
|
||||
"skipped": 2,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Voir les événements à venir sur l'accueil",
|
||||
@@ -309,7 +281,7 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
"passed": 5,
|
||||
"failed": 0,
|
||||
"skipped": 1,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Accéder au profil d'un participant",
|
||||
@@ -343,7 +315,7 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
"passed": 3,
|
||||
"failed": 0,
|
||||
"skipped": 4,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Accéder à la liste des événements depuis le profil",
|
||||
@@ -381,7 +353,7 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
"passed": 3,
|
||||
"failed": 0,
|
||||
"skipped": 2,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Accéder à la liste des inscrits d'un événement",
|
||||
@@ -411,7 +383,7 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
"passed": 5,
|
||||
"failed": 0,
|
||||
"skipped": 1,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Accéder à mon profil",
|
||||
@@ -445,7 +417,7 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
"passed": 2,
|
||||
"failed": 0,
|
||||
"skipped": 3,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Accéder aux paramètres de profil",
|
||||
@@ -475,7 +447,7 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
"passed": 5,
|
||||
"failed": 0,
|
||||
"skipped": 0,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Accéder au partage de profil",
|
||||
@@ -505,7 +477,7 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
"passed": 5,
|
||||
"failed": 0,
|
||||
"skipped": 0,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Accéder au partage depuis le profil",
|
||||
@@ -535,7 +507,7 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
"passed": 2,
|
||||
"failed": 0,
|
||||
"skipped": 1,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Accéder aux paramètres de notification",
|
||||
@@ -557,7 +529,7 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
"passed": 1,
|
||||
"failed": 0,
|
||||
"skipped": 2,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Accéder aux paramètres de notification",
|
||||
@@ -575,11 +547,11 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
},
|
||||
{
|
||||
"featureId": "us-26",
|
||||
"totalScenarios": 4,
|
||||
"passed": 3,
|
||||
"totalScenarios": 3,
|
||||
"passed": 2,
|
||||
"failed": 0,
|
||||
"skipped": 1,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Accéder au formulaire de relai d'événement",
|
||||
@@ -589,10 +561,6 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
"name": "Définir le rayon d'intérêt",
|
||||
"status": "skipped"
|
||||
},
|
||||
{
|
||||
"name": "Choisir une thématique",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Vérifier les champs obligatoires",
|
||||
"status": "passed"
|
||||
@@ -605,7 +573,7 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
"passed": 4,
|
||||
"failed": 0,
|
||||
"skipped": 1,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Accéder au profil pour voir la photo",
|
||||
@@ -635,7 +603,7 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
"passed": 0,
|
||||
"failed": 0,
|
||||
"skipped": 5,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Accéder aux détails d'un événement terminé",
|
||||
@@ -665,7 +633,7 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
"passed": 0,
|
||||
"failed": 0,
|
||||
"skipped": 3,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Accéder au bilan consolidé",
|
||||
@@ -687,7 +655,7 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
"passed": 0,
|
||||
"failed": 0,
|
||||
"skipped": 7,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Accéder à la création d'atelier",
|
||||
@@ -725,7 +693,7 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
"passed": 0,
|
||||
"failed": 0,
|
||||
"skipped": 5,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Accéder à la zone de notes personnelles",
|
||||
@@ -755,7 +723,7 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
"passed": 0,
|
||||
"failed": 0,
|
||||
"skipped": 5,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Voir les commentaires existants d'un atelier",
|
||||
@@ -785,7 +753,7 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
"passed": 0,
|
||||
"failed": 0,
|
||||
"skipped": 4,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Voir les ateliers d'un événement",
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* RefineProvider — mounts Refine over the backend this page runs.
|
||||
*
|
||||
* This is the app's data entry point: screens keep calling `useFestipodData()`,
|
||||
* and `FestipodDataContext` reads and writes through Refine's hooks, which land
|
||||
* on whichever backend the URL selected.
|
||||
*
|
||||
* ONE BACKEND PER PAGE is what shapes this file, and it is why the file is small.
|
||||
* The backend is read from the URL once (`providers/index.ts`, `variant.ts`) and
|
||||
* never changes while the page lives. Moving to another provider set is a link to
|
||||
* another variant, so it costs a full reload — and the reload is the mechanism,
|
||||
* not a cost to work around: it is what discards the previous backend's Refine
|
||||
* cache and session, with no code to undo them and nothing to migrate. Losing
|
||||
* access to the data of the variant being left is accepted.
|
||||
*
|
||||
* THE SIGN-IN IS THE BACKEND'S, and that is the deeper reason the variants are
|
||||
* isolated rather than switchable. It is not abstractable: on this base the data
|
||||
* SDK mounts its own full-screen barrier and owns the whole wallet experience
|
||||
* (`src/app/AuthGate.tsx` renders no access screen), while a Solid backend hands
|
||||
* the app an OIDC flow it has to render itself — a Pod-provider picker and a
|
||||
* callback route. One confiscates the screen, the other delegates a flow; no
|
||||
* single barrier serves both, so each variant brings its own.
|
||||
*
|
||||
* NO ROUTER PROVIDER, deliberately. Festipod has its own path-based router
|
||||
* (`app/router.tsx`); handing Refine a second one would give the app two actors
|
||||
* navigating the same history. Refine works without it — only its navigation
|
||||
* helpers (which this app does not call) go dormant.
|
||||
*/
|
||||
|
||||
import { createContext, useContext, useMemo, type ReactNode } from 'react';
|
||||
import { Refine } from '@refinedev/core';
|
||||
import { getBackend, initialBackendName, resourceNames, type RefineBackend } from './providers';
|
||||
|
||||
/** `undefined` when no backend is registered — see providers/index.ts. */
|
||||
const BackendContext = createContext<RefineBackend | undefined | null>(null);
|
||||
|
||||
/**
|
||||
* The backend this page runs, or `undefined` when none is registered. Callers
|
||||
* that READ or WRITE data must handle the undefined case — there is nothing to
|
||||
* read a connected wallet through, and pretending otherwise returns empty lists
|
||||
* that look like an empty wallet.
|
||||
*
|
||||
* Read it rather than importing one: a module-level import of a specific backend
|
||||
* is what would make the variant irrelevant.
|
||||
*/
|
||||
export function useBackend(): RefineBackend | undefined {
|
||||
const backend = useContext(BackendContext);
|
||||
if (backend === null) {
|
||||
throw new Error('useBackend must be used within a RefineProvider');
|
||||
}
|
||||
return backend;
|
||||
}
|
||||
|
||||
export function RefineProvider({ children }: { children: ReactNode }) {
|
||||
// Resolved once: the URL does not change under a page, and a variant change is
|
||||
// a reload that runs this afresh.
|
||||
const backend = useMemo(() => {
|
||||
const name = initialBackendName();
|
||||
return name ? getBackend(name) : undefined;
|
||||
}, []);
|
||||
|
||||
// Built once PER BACKEND, not per render: a fresh provider identity makes Refine
|
||||
// re-run every query behind it.
|
||||
const providers = useMemo(() => {
|
||||
if (!backend) {
|
||||
console.log('[refine] no backend registered — the app runs in demo mode');
|
||||
return undefined;
|
||||
}
|
||||
console.log(`[refine] backend: ${backend.name}`);
|
||||
return backend.build();
|
||||
}, [backend]);
|
||||
|
||||
// NO BACKEND, NO <Refine>. Mounting it without a data provider would give the
|
||||
// hooks something that answers nothing, which reads downstream as an empty
|
||||
// wallet. The context is still published, so callers get `undefined` rather
|
||||
// than a thrown "outside a provider".
|
||||
if (!backend || !providers) {
|
||||
return <BackendContext.Provider value={undefined}>{children}</BackendContext.Provider>;
|
||||
}
|
||||
|
||||
return (
|
||||
<BackendContext.Provider value={backend}>
|
||||
<Refine
|
||||
dataProvider={providers.dataProvider}
|
||||
liveProvider={providers.liveProvider}
|
||||
authProvider={providers.authProvider}
|
||||
resources={resourceNames(backend).map((resource) => ({ name: resource }))}
|
||||
options={{
|
||||
// `auto` is what makes the live provider actually subscribe behind every
|
||||
// query — without it the backend pushes changes and no hook is listening.
|
||||
liveMode: 'auto',
|
||||
// A write must be VISIBLE to the read that follows it. Before Refine the
|
||||
// app mutated the ORM object in place, so a screen — and a test — saw the
|
||||
// new value at once. Through a data provider the write goes out and the
|
||||
// list is re-read, and the re-read can still carry the old value: the
|
||||
// measured symptom was a participant count stuck on what the seed wrote,
|
||||
// with no error anywhere. `optimistic` puts the change into the cache
|
||||
// straight away and rolls it back if the write fails, which is the
|
||||
// behaviour that was lost — obtained from the framework instead of being
|
||||
// rebuilt by hand.
|
||||
mutationMode: 'optimistic',
|
||||
// Nothing about this app's usage is sent anywhere.
|
||||
disableTelemetry: true,
|
||||
// The app has no router, so Refine's unsaved-changes prompt (which hooks
|
||||
// navigation) has nothing to hook and would only warn.
|
||||
warnWhenUnsavedChanges: false,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Refine>
|
||||
</BackendContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { getBackend, listBackends, resourceNames } from './index';
|
||||
import type { FpEventData, FpMeetingPointData, FpUserData } from '../../data/types';
|
||||
|
||||
// The backend is registered as the default under a name no top-level route uses — the
|
||||
// variant prefix and the route would collide otherwise (providers/index.ts).
|
||||
test('registered as the default backend, under a URL-safe name', () => {
|
||||
const backends = listBackends();
|
||||
expect(backends).toHaveLength(1);
|
||||
const backend = getBackend('activitypods');
|
||||
expect(backends[0]).toBe(backend);
|
||||
expect(backend.name).toBe('activitypods');
|
||||
expect(['events', 'profile', 'home', 'settings', 'users']).not.toContain(backend.name);
|
||||
});
|
||||
|
||||
test('an unknown backend name throws rather than falling back', () => {
|
||||
expect(() => getBackend('nextgraph')).toThrow(/Unknown Refine backend/);
|
||||
});
|
||||
|
||||
// Refine resources are derived from the bindings; participations read through the
|
||||
// events resource, so they add no resource of their own.
|
||||
test('resource names are the deduplicated binding resources', () => {
|
||||
expect(resourceNames(getBackend('activitypods'))).toEqual(['events', 'meetingPoints', 'profiles']);
|
||||
});
|
||||
|
||||
// One identifier on this backend: the record URI is both the key Refine addresses the
|
||||
// record by and the id the app's entities reference (the contract's own guarantee).
|
||||
test('recordKey and domainId are the record URI, id or @id whichever is present', () => {
|
||||
const { event, meetingPoint, profile, participation } = getBackend('activitypods').bindings;
|
||||
const record = { id: 'http://localhost:3000/alice/data/1', name: 'X' };
|
||||
expect(event.recordKey(record)).toBe('http://localhost:3000/alice/data/1');
|
||||
expect(event.domainId(record)).toBe('http://localhost:3000/alice/data/1');
|
||||
expect(profile.recordKey({ '@id': 'http://localhost:3000/alice' })).toBe('http://localhost:3000/alice');
|
||||
expect(meetingPoint.domainId({ '@id': 'http://localhost:3000/alice/data/2' })).toBe(
|
||||
'http://localhost:3000/alice/data/2',
|
||||
);
|
||||
expect(() => event.recordKey({ name: 'no identifier' })).toThrow(/neither id nor @id/);
|
||||
// A participation is a collection member: a bare attendee URI is its own key.
|
||||
expect(participation.recordKey('http://localhost:3000/bob')).toBe('http://localhost:3000/bob');
|
||||
expect(participation.domainId({ id: 'http://localhost:3000/bob', name: 'Bob' })).toBe(
|
||||
'http://localhost:3000/bob',
|
||||
);
|
||||
});
|
||||
|
||||
// The contract's update fetches the record and merges shallowly, so a partial write
|
||||
// is safe: every binding declares the backend as patching.
|
||||
test('updateReplaces is false on every binding', () => {
|
||||
const { bindings } = getBackend('activitypods');
|
||||
for (const binding of Object.values(bindings)) {
|
||||
expect(binding.updateReplaces).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test('event toDomain maps an ActivityStreams record, splitting the datetimes', () => {
|
||||
const { event } = getBackend('activitypods').bindings;
|
||||
const domain = event.toDomain({
|
||||
id: 'http://localhost:3000/alice/data/550e8400-e29b-41d4-a716-446655440000',
|
||||
'@context': 'https://activitypods.org/context.json',
|
||||
type: 'Event',
|
||||
name: 'Soirée T04.a',
|
||||
content: 'Une soirée de test',
|
||||
startTime: '2026-10-10T18:00:00Z',
|
||||
endTime: '2026-10-10T23:00:00Z',
|
||||
location: 'Le Belle Électrique, Grenoble',
|
||||
image: 'https://example.org/cover.jpg',
|
||||
});
|
||||
expect(domain).toEqual({
|
||||
id: 'http://localhost:3000/alice/data/550e8400-e29b-41d4-a716-446655440000',
|
||||
title: 'Soirée T04.a',
|
||||
description: 'Une soirée de test',
|
||||
date: '2026-10-10',
|
||||
startDate: '2026-10-10',
|
||||
endDate: '2026-10-10',
|
||||
startTime: '18:00',
|
||||
endTime: '23:00',
|
||||
location: 'Le Belle Électrique, Grenoble',
|
||||
participantCount: 0,
|
||||
coverImage: 'https://example.org/cover.jpg',
|
||||
});
|
||||
});
|
||||
|
||||
test('event toDomain tolerates object forms and derives the display date like the forms do', () => {
|
||||
const { event } = getBackend('activitypods').bindings;
|
||||
const domain = event.toDomain({
|
||||
id: 'http://localhost:3000/alice/data/3',
|
||||
name: 'Résidence',
|
||||
startTime: '2026-02-16T09:00:00',
|
||||
endTime: '2026-02-20T18:00:00',
|
||||
location: { name: 'Le Revel, Rogues (30)' },
|
||||
image: { url: 'https://example.org/residence.jpg' },
|
||||
});
|
||||
expect(domain.date).toBe('2026-02-16 - 2026-02-20');
|
||||
expect(domain.startDate).toBe('2026-02-16');
|
||||
expect(domain.endDate).toBe('2026-02-20');
|
||||
expect(domain.startTime).toBe('09:00');
|
||||
expect(domain.location).toBe('Le Revel, Rogues (30)');
|
||||
expect(domain.coverImage).toBe('https://example.org/residence.jpg');
|
||||
});
|
||||
|
||||
test('event toValues composes the datetimes and never stores the derived fields', () => {
|
||||
const { event } = getBackend('activitypods').bindings;
|
||||
const data: Omit<FpEventData, 'id'> = {
|
||||
title: 'Atelier low-tech',
|
||||
description: 'Fabriquer des objets',
|
||||
date: 'Sam. 8 fév. · 14h00',
|
||||
startDate: '2026-02-08',
|
||||
endDate: '2026-02-08',
|
||||
startTime: '14:00',
|
||||
endTime: '17:00',
|
||||
location: 'La Maison du Vélo, Lyon',
|
||||
distance: 3,
|
||||
participantCount: 12,
|
||||
coverImage: 'https://example.org/atelier.jpg',
|
||||
themes: ['Tech'],
|
||||
};
|
||||
const values = event.toValues(data);
|
||||
expect(values).toEqual({
|
||||
name: 'Atelier low-tech',
|
||||
content: 'Fabriquer des objets',
|
||||
startTime: '2026-02-08T14:00',
|
||||
endTime: '2026-02-08T17:00',
|
||||
// `location` is `@type: @id` in the platform context: the display text is
|
||||
// written as an inline named node so it round-trips as `as:name`, never as a
|
||||
// text value resolved into a garbage IRI.
|
||||
location: { name: 'La Maison du Vélo, Lyon' },
|
||||
image: 'https://example.org/atelier.jpg',
|
||||
});
|
||||
});
|
||||
|
||||
test('event toValues keeps a date-only end and drops a time-only start', () => {
|
||||
const { event } = getBackend('activitypods').bindings;
|
||||
expect(
|
||||
event.toValues({
|
||||
title: 'T',
|
||||
description: '',
|
||||
date: '',
|
||||
startDate: '2026-03-01',
|
||||
endDate: '2026-03-03',
|
||||
location: '',
|
||||
participantCount: 0,
|
||||
}),
|
||||
).toEqual({ name: 'T', content: '', startTime: '2026-03-01', endTime: '2026-03-03', location: { name: '' } });
|
||||
expect(
|
||||
event.toValues({ title: 'T', description: '', date: '', startTime: '09:00', location: '', participantCount: 0 }),
|
||||
).toEqual({ name: 'T', content: '', location: { name: '' } });
|
||||
});
|
||||
|
||||
test('event toPatch sends only what changed', () => {
|
||||
const { event } = getBackend('activitypods').bindings;
|
||||
expect(event.toPatch({ title: 'Nouveau titre' })).toEqual({ name: 'Nouveau titre' });
|
||||
});
|
||||
|
||||
test('meetingPoint toDomain reads the context link and the host from dc:creator', () => {
|
||||
const { meetingPoint } = getBackend('activitypods').bindings;
|
||||
const objectForm = meetingPoint.toDomain({
|
||||
id: 'http://localhost:3000/alice/data/mp-1',
|
||||
type: 'Event',
|
||||
name: 'Rdv entrée',
|
||||
content: 'On se retrouve là-bas',
|
||||
'as:context': { id: 'http://localhost:3000/alice/data/event-1' },
|
||||
// The Pod's DocumentTaggerMixin stamps this on create — the host convention.
|
||||
'dc:creator': 'http://localhost:3000/alice',
|
||||
location: { name: 'Café de la Place' },
|
||||
});
|
||||
expect(objectForm).toEqual({
|
||||
id: 'http://localhost:3000/alice/data/mp-1',
|
||||
eventId: 'http://localhost:3000/alice/data/event-1',
|
||||
hostId: 'http://localhost:3000/alice',
|
||||
title: 'Rdv entrée',
|
||||
description: 'On se retrouve là-bas',
|
||||
location: 'Café de la Place',
|
||||
time: '',
|
||||
});
|
||||
// The full IRI form (a context that does not compact `dc`) and the legacy
|
||||
// `organizer` fallback both still resolve the host.
|
||||
expect(
|
||||
meetingPoint.toDomain({
|
||||
id: 'mp-full-iri',
|
||||
'dc:creator': { '@id': 'http://localhost:3000/alice' },
|
||||
}).hostId,
|
||||
).toBe('http://localhost:3000/alice');
|
||||
const stringForm = meetingPoint.toDomain({
|
||||
id: 'http://localhost:3000/alice/data/mp-2',
|
||||
'as:context': 'http://localhost:3000/alice/data/event-1',
|
||||
organizer: { id: 'http://localhost:3000/alice' },
|
||||
location: 'Station Bellecour',
|
||||
});
|
||||
expect(stringForm.eventId).toBe('http://localhost:3000/alice/data/event-1');
|
||||
expect(stringForm.hostId).toBe('http://localhost:3000/alice');
|
||||
expect(stringForm.title).toBeUndefined();
|
||||
});
|
||||
|
||||
test('meetingPoint toValues writes the context and the named location, never organizer', () => {
|
||||
const { meetingPoint } = getBackend('activitypods').bindings;
|
||||
const values = meetingPoint.toValues({
|
||||
eventId: 'http://localhost:3000/alice/data/event-1',
|
||||
hostId: 'http://localhost:3000/alice',
|
||||
title: 'Rdv entrée',
|
||||
description: 'Sous l’horloge',
|
||||
place: 'Entrée principale',
|
||||
location: 'Café de la Place',
|
||||
time: '30 min avant',
|
||||
});
|
||||
expect(values).toEqual({
|
||||
name: 'Rdv entrée',
|
||||
content: 'Sous l’horloge',
|
||||
location: { name: 'Café de la Place' },
|
||||
'as:context': 'http://localhost:3000/alice/data/event-1',
|
||||
});
|
||||
// The host is the Pod-stamped creator; nothing here writes an `organizer` term
|
||||
// the platform context does not define (it would not round-trip).
|
||||
expect(values).not.toHaveProperty('organizer');
|
||||
});
|
||||
|
||||
test('profile toDomain maps the AS2 profile fields and derives the initials', () => {
|
||||
const { profile } = getBackend('activitypods').bindings;
|
||||
const domain = profile.toDomain({
|
||||
id: 'http://localhost:3000/alice',
|
||||
type: 'Profile',
|
||||
name: 'Marie Dupont',
|
||||
preferredUsername: 'mariedupont',
|
||||
summary: 'Organisatrice d’ateliers',
|
||||
location: { name: 'Lyon' },
|
||||
});
|
||||
expect(domain).toEqual({
|
||||
id: 'http://localhost:3000/alice',
|
||||
name: 'Marie Dupont',
|
||||
initials: 'MD',
|
||||
username: 'mariedupont',
|
||||
bio: 'Organisatrice d’ateliers',
|
||||
city: 'Lyon',
|
||||
});
|
||||
expect(profile.toDomain({ id: 'http://localhost:3000/bob', name: 'Bob' })).toMatchObject({
|
||||
initials: 'BO',
|
||||
username: '',
|
||||
});
|
||||
});
|
||||
|
||||
test('profile toValues writes the mapped fields and none of the derived ones', () => {
|
||||
const { profile } = getBackend('activitypods').bindings;
|
||||
const values = profile.toValues({
|
||||
name: 'Marie Dupont',
|
||||
initials: 'MD',
|
||||
username: 'mariedupont',
|
||||
role: 'Créatrice',
|
||||
isPublic: true,
|
||||
bio: 'Bio',
|
||||
city: 'Lyon',
|
||||
eventsCount: 23,
|
||||
friendsCount: 7,
|
||||
participationsCount: 4,
|
||||
});
|
||||
expect(values).toEqual({
|
||||
name: 'Marie Dupont',
|
||||
preferredUsername: 'mariedupont',
|
||||
summary: 'Bio',
|
||||
// `location` is `@type: @id`: the city is a named node, not a bare text value.
|
||||
location: { name: 'Lyon' },
|
||||
});
|
||||
});
|
||||
|
||||
test('participation toDomain maps a bare URI member and a dereferenced one', () => {
|
||||
const { participation } = getBackend('activitypods').bindings;
|
||||
expect(participation.toDomain('http://localhost:3000/bob')).toEqual({
|
||||
id: 'http://localhost:3000/bob',
|
||||
eventId: '',
|
||||
userId: 'http://localhost:3000/bob',
|
||||
isConfirmed: true,
|
||||
});
|
||||
expect(participation.toDomain({ id: 'http://localhost:3000/bob', name: 'Bob' })).toEqual({
|
||||
id: 'http://localhost:3000/bob',
|
||||
eventId: '',
|
||||
userId: 'http://localhost:3000/bob',
|
||||
isConfirmed: true,
|
||||
});
|
||||
expect(() => participation.toDomain({ name: 'no reference' })).toThrow(/no attendee reference/);
|
||||
});
|
||||
|
||||
test('participation rejects writes: joins and leaves are not Refine writes', () => {
|
||||
const { participation } = getBackend('activitypods').bindings;
|
||||
expect(() =>
|
||||
participation.toValues({ eventId: 'e', userId: 'u', isConfirmed: true }),
|
||||
).toThrow(/read-only/);
|
||||
expect(() => participation.toPatch({ isConfirmed: false })).toThrow(/read-only/);
|
||||
});
|
||||
|
||||
// The visibility declarations mirror the product's confidentiality model: events,
|
||||
// meeting points and participations are public; the profile link is protected.
|
||||
test('visibility declarations follow the confidentiality model', () => {
|
||||
const { bindings } = getBackend('activitypods');
|
||||
expect(bindings.event.visibility).toBe('public');
|
||||
expect(bindings.meetingPoint.visibility).toBe('public');
|
||||
expect(bindings.participation.visibility).toBe('public');
|
||||
expect(bindings.profile.visibility).toBe('protected');
|
||||
});
|
||||
|
||||
// Type-level guard: the exported backend satisfies the seam contract as a whole, and
|
||||
// a written meeting point reads back with the unmapped display fields empty.
|
||||
test('backend satisfies the RefineBackend contract shape', () => {
|
||||
const backend = getBackend('activitypods');
|
||||
expect(typeof backend.build).toBe('function');
|
||||
expect(Object.keys(backend.bindings).sort()).toEqual(['event', 'meetingPoint', 'participation', 'profile']);
|
||||
const sample: FpMeetingPointData = {
|
||||
id: 'mp',
|
||||
eventId: 'event-1',
|
||||
location: 'Café de la Place',
|
||||
time: '15h30',
|
||||
};
|
||||
const stored = { id: 'mp', ...backend.bindings.meetingPoint.toValues(sample) };
|
||||
expect(backend.bindings.meetingPoint.toDomain(stored)).toMatchObject({
|
||||
id: 'mp',
|
||||
eventId: 'event-1',
|
||||
title: undefined,
|
||||
location: 'Café de la Place',
|
||||
time: '',
|
||||
});
|
||||
});
|
||||
|
||||
// Events and meeting points share the as/Event container: the presence of
|
||||
// `as:context` is the ONLY distinction, and the discrimination lives in the
|
||||
// bindings (isInstance) so the reader splits a raw list without learning the
|
||||
// predicate.
|
||||
test('event and meetingPoint isInstance discriminate on the as:context link', () => {
|
||||
const { event, meetingPoint } = getBackend('activitypods').bindings;
|
||||
const plain = { id: 'http://localhost:3000/alice/data/1', type: 'Event', name: 'Soirée' };
|
||||
const grafted = { ...plain, id: 'http://localhost:3000/alice/data/2', 'as:context': 'http://localhost:3000/alice/data/0' };
|
||||
expect(event.isInstance?.(plain)).toBe(true);
|
||||
expect(event.isInstance?.(grafted)).toBe(false);
|
||||
expect(meetingPoint.isInstance?.(plain)).toBe(false);
|
||||
expect(meetingPoint.isInstance?.(grafted)).toBe(true);
|
||||
});
|
||||
|
||||
// The attendees collection travels inside the event record: members embedded
|
||||
// when the reader may read them, a bare collection URI when not, absent when
|
||||
// nobody joined. `totalItems` survives without members — the count stays total
|
||||
// for a reader who may resolve no profile (the Alice/Bob rule).
|
||||
test('participation membersOf reads the embedded apods:attendees collection in its shapes', () => {
|
||||
const { participation } = getBackend('activitypods').bindings;
|
||||
const bob = 'http://localhost:3000/bob';
|
||||
|
||||
const embedded = participation.membersOf?.({
|
||||
id: 'ev-1',
|
||||
'apods:attendees': {
|
||||
id: 'http://localhost:3000/alice/data/as/collection/att-1',
|
||||
totalItems: 1,
|
||||
items: [bob],
|
||||
},
|
||||
});
|
||||
expect(embedded).toEqual({ members: [bob], total: 1 });
|
||||
|
||||
const prefixed = participation.membersOf?.({
|
||||
id: 'ev-1',
|
||||
'apods:attendees': { totalItems: 2, 'as:items': [{ id: bob }] },
|
||||
});
|
||||
expect(prefixed?.members).toEqual([{ id: bob }]);
|
||||
expect(prefixed?.total).toBe(2);
|
||||
|
||||
// A collection URI without an embed: nothing readable, no count.
|
||||
expect(participation.membersOf?.({ id: 'ev-1', 'apods:attendees': 'http://localhost:3000/alice/data/as/collection/att-1' })).toBeUndefined();
|
||||
|
||||
// No collection predicate at all → nobody joined yet.
|
||||
expect(participation.membersOf?.({ id: 'ev-1', name: 'Soirée' })).toBeUndefined();
|
||||
|
||||
// totalItems without items still yields the count (members unknown → []).
|
||||
const totalOnly = participation.membersOf?.({
|
||||
id: 'ev-1',
|
||||
'apods:attendees': { id: 'col-1', totalItems: 3 },
|
||||
});
|
||||
expect(totalOnly).toEqual({ members: [], total: 3 });
|
||||
});
|
||||
|
||||
// The reader stamps the host record's id onto each member — a member carries
|
||||
// no link back to what it attends.
|
||||
test('a mapped member is a confirmed participation keyed by the attendee', () => {
|
||||
const { participation } = getBackend('activitypods').bindings;
|
||||
const member = participation.toDomain('http://localhost:3000/bob');
|
||||
expect(member).toEqual({
|
||||
id: 'http://localhost:3000/bob',
|
||||
eventId: '',
|
||||
userId: 'http://localhost:3000/bob',
|
||||
isConfirmed: true,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,481 @@
|
||||
/**
|
||||
* The ActivityPods backend — `@activitypods/refine-providers` behind the seam.
|
||||
*
|
||||
* This backend is specified by the pinned reconstruction
|
||||
* `.project/concepts/data-layer/activitypods-providers/contract_activitypods-providers.md`
|
||||
* and by nothing else: the package's sources are never read at work time, so every
|
||||
* behaviour relied on here is either a guarantee of that contract or a fact recorded
|
||||
* in the project's own documents (brief, hand-off, cookbook).
|
||||
*
|
||||
* WHAT A RECORD IS. A record is a JSON-LD resource whose `id` (or `@id`) is its own
|
||||
* URI, and that URI is the ONLY identifier: `getOne`, `update` and `deleteOne` use it
|
||||
* directly as the URL they fetch. The seam carries `recordKey` and `domainId`
|
||||
* separately because they differ on the other targeted backend; here they coincide,
|
||||
* so both return the record URI.
|
||||
*
|
||||
* UPDATE MERGES, IT DOES NOT REPLACE. `update` fetches the current resource and PUTs
|
||||
* `{...current, ...variables}`, so a partial write is safe — the backend PATCHES and
|
||||
* `updateReplaces` is `false`. The merge is SHALLOW: a nested object sent in variables
|
||||
* replaces its counterpart whole. Every value this module sends is a flat scalar, so
|
||||
* mapping only the changed fields (`toPatch` = same mapper as `toValues`, restricted
|
||||
* by what the caller passes) is both safe and complete.
|
||||
*
|
||||
* THE VOCABULARY IS ACTIVITYSTREAMS, NOT Festipod's. Directive of 2026-10-09
|
||||
* (concept `data-layer`, `brief_2026-10-09_activitypods-first-backend`): no new
|
||||
* shapes, interoperability first. So an event is an `as/Event` and a meeting point is
|
||||
* a plain `as/Event` distinguished by an `as:context` link to its anchor event (the
|
||||
* `as/Event` shape is open, so the extra predicate breaks nothing); a profile is an
|
||||
* `as/Profile`. Fields the app's domain type carries but ActivityStreams has no slot
|
||||
* for are left unmapped on purpose, one comment per field — recording them would need
|
||||
* a Festipod-owned predicate, and a custom prefix must be registered at prefix.cc
|
||||
* before the Pod provider accepts it. App fields with no slot read back as their
|
||||
* type's empty value (`''` / `undefined`) and are re-derived by the reader.
|
||||
*
|
||||
* EVENTS AND MEETING POINTS SHARE ONE CONTAINER. Container discovery is per type
|
||||
* (TypeIndex `solid:forClass`), and both resources resolve to `as:Event`, so both
|
||||
* resolve to the SAME container: a list of events also contains meeting points. The
|
||||
* presence of `as:context` is what distinguishes a meeting point, so the reader must
|
||||
* filter on it — that is where the distinction lives, since the container does not.
|
||||
*
|
||||
* PARTICIPATIONS ARE A COLLECTION, READ EMBEDDED. The production precedent
|
||||
* (hand-off, reconnaissance of 2026-10-09): a join adds the attendee to the event's
|
||||
* `apods:attendees` collection and a leave removes it, so membership IS the
|
||||
* attendance. That collection is per-event and is NOT a TypeIndex container, so it is
|
||||
* not a listed resource here: it arrives inside the event record — the collection URI
|
||||
* under `apods:attendees`, its members dereferenced when the reader may read them and
|
||||
* as bare URIs otherwise — and it can also be read by `getOne` on the collection URI,
|
||||
* which fetches a URI directly. Reading members is done from the event record rather
|
||||
* than by `getMany`-ing member URIs because `getMany` silently drops what it cannot
|
||||
* fetch, which would erase exactly the "reference that does not resolve" state the
|
||||
* app model needs. Writing a participation is an `as:Join`/`as:Leave` flow owned by
|
||||
* the on-behalf backend, not a Refine `create` — so this binding throws on write.
|
||||
* A member carries no link to its event: the reader knows the event because it read
|
||||
* the collection from it, which is why `eventId` maps to `''` here.
|
||||
*
|
||||
* THE RESOURCE CONFIG CONFIGURES `types` ONLY. `types` wins over `shapeTreeUri` when
|
||||
* both are set (the opposite of the configuration type's own documentation), so
|
||||
* exactly one is configured, and `types` names the class directly.
|
||||
*
|
||||
* THE CONTEXT IS CALLER-SUPPLIED. `jsonContext` is sent as `@context` on every create
|
||||
* and update and is what expands the configured CURIE types. The platform context
|
||||
* (`https://activitypods.org/context.json`) is used because it defines the `as:` and
|
||||
* `apods:` prefixes and its round-trip on a real pod is verified (concept
|
||||
* `data-layer`, `cookbook_local-activitypods-platform`); the package default, the
|
||||
* bare ActivityStreams context, does not define the `as:` prefix a CURIE needs.
|
||||
*
|
||||
* THE AUTH PROVIDER COMES WITH THE SET. The contract ships all three providers
|
||||
* together, and the data/live providers take the auth provider as a parameter. Its
|
||||
* `appStatusCheckInterval` is disabled deliberately: construction installs a
|
||||
* `setInterval` and a `visibilitychange` listener with no teardown (usage
|
||||
* declaration, friction 1). Driving `getAppStatus()` on Festipod's own schedule is
|
||||
* the planned replacement, but it is NOT wired yet: the contract documents the
|
||||
* method's name only, not the shape of the `AppStatus` result, so the follow-up is
|
||||
* parked until that surface is pinned (handoff, follow-ups). The `clientId` names
|
||||
* the app; the app description document it points to
|
||||
* is served by Festipod's own backend, and the values resolve from the runtime
|
||||
* config — the SAME source the AuthGate flow uses, so both halves agree.
|
||||
*/
|
||||
|
||||
import {
|
||||
authProvider as createApodsAuthProvider,
|
||||
dataProvider as createApodsDataProvider,
|
||||
liveProvider as createApodsLiveProvider,
|
||||
} from '@activitypods/refine-providers';
|
||||
import type { FpEventData, FpMeetingPointData, FpParticipationData, FpUserData } from '../../data/types';
|
||||
import { resolveActivityPodsAuthConfig } from '../../utils/runtimeConfig';
|
||||
import type { RecordBinding, RecordBindings, RefineBackend } from './types';
|
||||
|
||||
/** One JSON-LD record as the data provider returns it: predicates verbatim, values untyped. */
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
function asRecord(value: unknown): JsonRecord | undefined {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
? (value as JsonRecord)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' ? value : undefined;
|
||||
}
|
||||
|
||||
/** First string of a predicate that may hold a single value or an array of them. */
|
||||
function firstString(value: unknown): string | undefined {
|
||||
return asString(value) ?? (Array.isArray(value) ? asString(value[0]) : undefined);
|
||||
}
|
||||
|
||||
/** First entry of a predicate that may hold a single value or an array of them. */
|
||||
function firstEntry(value: unknown): unknown {
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
}
|
||||
|
||||
/**
|
||||
* The single identifier. `id` first, `@id` otherwise; a record carrying neither
|
||||
* raises, per the contract's own rule.
|
||||
*/
|
||||
function recordId(record: unknown): string {
|
||||
const r = asRecord(record);
|
||||
const id = r ? (asString(r['id']) ?? asString(r['@id'])) : undefined;
|
||||
if (!id) {
|
||||
throw new Error('ActivityPods record carries neither id nor @id');
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
/** A linked value in an ActivityStreams record: a bare IRI string, or an object carrying id/@id. */
|
||||
function linkId(value: unknown): string | undefined {
|
||||
return asString(value) ?? (() => {
|
||||
const o = asRecord(value);
|
||||
return o ? (asString(o['id']) ?? asString(o['@id'])) : undefined;
|
||||
})();
|
||||
}
|
||||
|
||||
/** A display value: a bare string, or a named object such as an `as:Place`. */
|
||||
function namedValue(value: unknown): string | undefined {
|
||||
const direct = asString(value);
|
||||
if (direct) return direct;
|
||||
const o = asRecord(value);
|
||||
return o ? asString(o['name']) ?? asString(o['url']) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* A display string written into a predicate the platform context types as `@id`
|
||||
* (`location` → `as:location` with `"@type": "@id"`). A bare text value is
|
||||
* resolved against the base IRI into a garbage IRI on expansion, so the text is
|
||||
* wrapped in an inline named node — expanded to a blank node carrying `as:name`,
|
||||
* which IS defined in the context — and `namedValue` reads it back.
|
||||
*/
|
||||
function namedTextValue(text: string): Record<string, unknown> {
|
||||
return { name: text };
|
||||
}
|
||||
|
||||
/** A media value: a bare URL string, or an object such as an `as:Link` carrying url. */
|
||||
function urlValue(value: unknown): string | undefined {
|
||||
const direct = asString(value);
|
||||
if (direct) return direct;
|
||||
const o = asRecord(value);
|
||||
return o ? (asString(o['url']) ?? asString(o['href'])) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose the form's separate date and time fields into one AS2 `startTime`/`endTime`
|
||||
* value. A date alone is kept as a date; a time alone is dropped — an `as:startTime`
|
||||
* without a date is not a machine value any consumer can use, and the creation forms
|
||||
* guarantee a date (submit is disabled without one).
|
||||
*/
|
||||
function composeDateTime(date: string | undefined, time: string | undefined): string | undefined {
|
||||
if (date) return time ? `${date}T${time}` : date;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** The inverse of `composeDateTime`, tolerant of real-world datetimes (offsets, seconds). */
|
||||
function splitDateTime(value: unknown): { date?: string; time?: string } {
|
||||
const s = asString(value);
|
||||
if (!s) return {};
|
||||
const sep = s.indexOf('T');
|
||||
if (sep === -1) {
|
||||
return /^\d{4}-\d{2}-\d{2}$/.test(s) ? { date: s } : {};
|
||||
}
|
||||
const date = s.slice(0, sep);
|
||||
const rest = s.slice(sep + 1);
|
||||
const time = /^\d{2}:\d{2}/.test(rest) ? rest.slice(0, 5) : undefined;
|
||||
return time ? { date, time } : { date };
|
||||
}
|
||||
|
||||
/** The display date the forms compute: the start date, extended with the end date when it differs. */
|
||||
function dateLabel(startDate: string | undefined, endDate: string | undefined): string {
|
||||
if (!startDate) return '';
|
||||
return endDate && endDate !== startDate ? `${startDate} - ${endDate}` : startDate;
|
||||
}
|
||||
|
||||
/** Initials for avatar display, derived from the display name — no AS2 slot exists for them. */
|
||||
function initialsOf(name: string): string {
|
||||
const parts = name.trim().split(/\s+/).filter((p) => p.length > 0);
|
||||
const first = parts[0];
|
||||
if (!first) return '';
|
||||
const last = parts.length > 1 ? parts[parts.length - 1] : undefined;
|
||||
if (!last) return first.slice(0, 2).toUpperCase();
|
||||
return (first.slice(0, 1) + last.slice(0, 1)).toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* The event field mapping, shared by `toValues` and `toPatch`: every mapped value is
|
||||
* a flat scalar, so sending only the fields that changed is complete under the
|
||||
* backend's shallow merge.
|
||||
* NOT mapped, on purpose: `date` (a display label, re-derived from the machine dates
|
||||
* on read), `participantCount` (derived from the participations at read time — never
|
||||
* a stored, incremented counter), `distance` (per-viewer, not record data) and
|
||||
* `themes` (no slot in the SHEX shape either).
|
||||
*/
|
||||
function eventValues(data: Partial<FpEventData>): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
if (data.title !== undefined) out['name'] = data.title;
|
||||
if (data.description !== undefined) out['content'] = data.description;
|
||||
const startTime = composeDateTime(data.startDate, data.startTime);
|
||||
if (startTime !== undefined) out['startTime'] = startTime;
|
||||
const endTime = composeDateTime(data.endDate, data.endTime);
|
||||
if (endTime !== undefined) out['endTime'] = endTime;
|
||||
if (data.location !== undefined) out['location'] = namedTextValue(data.location);
|
||||
if (data.coverImage !== undefined) out['image'] = data.coverImage;
|
||||
return out;
|
||||
}
|
||||
|
||||
function eventFromRecord(record: unknown): FpEventData {
|
||||
const r = asRecord(record);
|
||||
const start = splitDateTime(r?.['startTime']);
|
||||
const end = splitDateTime(r?.['endTime']);
|
||||
return {
|
||||
id: recordId(record),
|
||||
title: firstString(r?.['name']) ?? '',
|
||||
description: firstString(r?.['content']) ?? '',
|
||||
date: dateLabel(start.date, end.date),
|
||||
startDate: start.date,
|
||||
endDate: end.date,
|
||||
startTime: start.time,
|
||||
endTime: end.time,
|
||||
location: namedValue(r?.['location']) ?? '',
|
||||
// The count is derived by the reader from the event's participations; a freshly
|
||||
// mapped event has been counted by nobody yet, so this is a placeholder the
|
||||
// reader overwrites — never a value read off the record.
|
||||
participantCount: 0,
|
||||
coverImage: urlValue(r?.['image']),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The meeting point field mapping. A meeting point is an `as/Event` plus `as:context`
|
||||
* pointing at the anchor event. THE HOST IS NOT A STORED PREDICATE: the Pod's
|
||||
* DocumentTaggerMixin stamps `dc:creator` on every created resource, and the
|
||||
* creator IS the host (the welcometomyplace convention, also what the on-behalf
|
||||
* backend reads) — so the host is read back from `dc:creator`, never written.
|
||||
* `organizer` is not a term of the platform context, so writing it would not
|
||||
* round-trip and the host would be lost on read.
|
||||
* NOT mapped, on purpose: `time` (a display string such as '30 min avant', which is
|
||||
* not an AS2 datetime — unmapped until the creation flow produces one), `place` (AS2
|
||||
* has a single location slot, taken by `location`) and `inbox` (nothing provisions an
|
||||
* inbox on this side yet — the on-behalf backend owns that when it lands).
|
||||
*/
|
||||
function meetingPointValues(data: Partial<FpMeetingPointData>): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
if (data.title !== undefined) out['name'] = data.title;
|
||||
if (data.description !== undefined) out['content'] = data.description;
|
||||
if (data.location !== undefined) out['location'] = namedTextValue(data.location);
|
||||
if (data.eventId !== undefined) out['as:context'] = data.eventId;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The Pod-stamped creator of a record, tolerant of the term's compact and full forms. */
|
||||
function creatorId(record: JsonRecord | undefined): string | undefined {
|
||||
return (
|
||||
linkId(firstEntry(record?.['dc:creator'])) ??
|
||||
linkId(firstEntry(record?.['http://purl.org/dc/terms/creator']))
|
||||
);
|
||||
}
|
||||
|
||||
function meetingPointFromRecord(record: unknown): FpMeetingPointData {
|
||||
const r = asRecord(record);
|
||||
return {
|
||||
id: recordId(record),
|
||||
// A meeting point IS an `as/Event` with a context; one without it is an event,
|
||||
// and the reader tells the two apart by this very field.
|
||||
eventId: linkId(r?.['as:context']) ?? '',
|
||||
// The host is the Pod-stamped creator; the `organizer` fallback is kept for
|
||||
// robustness against records written before the convention was adopted.
|
||||
hostId: creatorId(r) ?? linkId(r?.['organizer']),
|
||||
title: firstString(r?.['name']),
|
||||
description: firstString(r?.['content']),
|
||||
location: namedValue(r?.['location']) ?? '',
|
||||
time: '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The profile field mapping. A profile is an `as/Profile`.
|
||||
* NOT mapped, on purpose: `initials` (derived from the name on read), `role` (no AS2
|
||||
* slot), `isPublic` (visibility is enforced by the backend's ACLs, not stored on the
|
||||
* record) and the three counts (derived by the reader).
|
||||
*/
|
||||
function profileValues(data: Partial<FpUserData>): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
if (data.name !== undefined) out['name'] = data.name;
|
||||
if (data.username !== undefined) out['preferredUsername'] = data.username;
|
||||
if (data.bio !== undefined) out['summary'] = data.bio;
|
||||
if (data.city !== undefined) out['location'] = namedTextValue(data.city);
|
||||
return out;
|
||||
}
|
||||
|
||||
function profileFromRecord(record: unknown): FpUserData {
|
||||
const r = asRecord(record);
|
||||
const name = firstString(r?.['name']) ?? '';
|
||||
return {
|
||||
id: recordId(record),
|
||||
name,
|
||||
initials: initialsOf(name),
|
||||
username: firstString(r?.['preferredUsername']) ?? '',
|
||||
bio: firstString(r?.['summary']),
|
||||
city: namedValue(r?.['location']),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* One member of an event's `apods:attendees` collection, mapped to a participation.
|
||||
* A member is a reference to the attendee: a bare URI when the reader may not
|
||||
* dereference it, an object when the event read dereferenced it. Membership IS the
|
||||
* attendance — a leave removes the member, so a member reads as confirmed. A member
|
||||
* carries no link to its event (`eventId` is `''`; the reader knows the event it read
|
||||
* the collection from) — the member shape is the one fact the pinned reconstruction
|
||||
* does not document, recorded as friction 5 of the usage declaration.
|
||||
*/
|
||||
function participationFromMember(member: unknown): FpParticipationData {
|
||||
const attendee = linkId(member);
|
||||
if (!attendee) {
|
||||
throw new Error('apods:attendees member carries no attendee reference');
|
||||
}
|
||||
return { id: attendee, eventId: '', userId: attendee, isConfirmed: true };
|
||||
}
|
||||
|
||||
function participationWrite(): never {
|
||||
throw new Error(
|
||||
'participations are read-only through this binding: a join or leave is an as:Join/as:Leave flow owned by the on-behalf backend, not a Refine write',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The embedded `apods:attendees` read, one per event record. Three shapes:
|
||||
* an object carrying `items`/`as:items` (members embedded — dereferenced when
|
||||
* the reader may read them), a bare collection URI (the collection exists but
|
||||
* does not embed), or absent (nobody joined). `totalItems` is reported
|
||||
* whenever the collection object carries it. The member shape itself is the
|
||||
* reconstruction's known gap (usage declaration, friction 5): a member is read
|
||||
* as a reference — bare URI, or object with id/@id — and membership IS the
|
||||
* attendance.
|
||||
*/
|
||||
function attendeesFromRecord(record: unknown): { members: unknown[]; total?: number } | undefined {
|
||||
const r = asRecord(record);
|
||||
const attendees = firstEntry(r?.['apods:attendees']);
|
||||
const collection = asRecord(attendees);
|
||||
if (!collection) return undefined;
|
||||
const totalValue = collection['totalItems'];
|
||||
const total = typeof totalValue === 'number' ? totalValue : undefined;
|
||||
// `items` is a list, not a single-or-array predicate: take it as it comes.
|
||||
const rawItems = collection['items'] ?? collection['as:items'];
|
||||
let members: unknown[] = [];
|
||||
if (Array.isArray(rawItems)) {
|
||||
members = rawItems;
|
||||
} else if (rawItems !== undefined && rawItems !== null) {
|
||||
members = [rawItems];
|
||||
}
|
||||
if (members.length === 0 && total === undefined) return undefined;
|
||||
return { members, total: total ?? members.length };
|
||||
}
|
||||
|
||||
const eventBinding: RecordBinding<FpEventData> = {
|
||||
recordKey: recordId,
|
||||
domainId: recordId,
|
||||
toDomain: eventFromRecord,
|
||||
toValues: (data) => eventValues(data),
|
||||
toPatch: (updates) => eventValues(updates),
|
||||
// Events and meeting points share the as/Event container; an `as:context` link
|
||||
// is what makes a record a MEETING point, so its absence is the event test.
|
||||
isInstance: (record) => linkId(asRecord(record)?.['as:context']) === undefined,
|
||||
updateReplaces: false,
|
||||
resource: 'events',
|
||||
visibility: 'public',
|
||||
};
|
||||
|
||||
const meetingPointBinding: RecordBinding<FpMeetingPointData> = {
|
||||
recordKey: recordId,
|
||||
domainId: recordId,
|
||||
toDomain: meetingPointFromRecord,
|
||||
toValues: (data) => meetingPointValues(data),
|
||||
toPatch: (updates) => meetingPointValues(updates),
|
||||
// The distinction lives in the record, not the container — see the event binding.
|
||||
isInstance: (record) => linkId(asRecord(record)?.['as:context']) !== undefined,
|
||||
updateReplaces: false,
|
||||
resource: 'meetingPoints',
|
||||
// A meeting point hangs off a public event; who joins it is the protected fact, not the point itself.
|
||||
visibility: 'public',
|
||||
};
|
||||
|
||||
const profileBinding: RecordBinding<FpUserData> = {
|
||||
recordKey: recordId,
|
||||
domainId: recordId,
|
||||
toDomain: profileFromRecord,
|
||||
toValues: (data) => profileValues(data),
|
||||
toPatch: (updates) => profileValues(updates),
|
||||
updateReplaces: false,
|
||||
resource: 'profiles',
|
||||
// The link between a participation and a person is protected: only a connection
|
||||
// reading the profile can recognise who it is (functional-domain confidentiality).
|
||||
visibility: 'protected',
|
||||
};
|
||||
|
||||
const participationBinding: RecordBinding<FpParticipationData> = {
|
||||
recordKey: (member) => linkId(member) ?? participationWrite(),
|
||||
domainId: (member) => linkId(member) ?? participationWrite(),
|
||||
toDomain: participationFromMember,
|
||||
toValues: participationWrite,
|
||||
toPatch: participationWrite,
|
||||
// The collection travels with the event record: an embedded object whose
|
||||
// `items`/`as:items` hold the members (dereferenced when the reader may read
|
||||
// them), a bare collection URI when it does not embed, absent when nobody
|
||||
// joined yet. `totalItems` survives even when members are not dereferenced,
|
||||
// so the COUNT can stay total when the members are not readable — the
|
||||
// Alice/Bob rule. No embed → undefined (nothing readable, count unknown → 0).
|
||||
membersOf: attendeesFromRecord,
|
||||
updateReplaces: false,
|
||||
// Read through the events resource: the collection is embedded in the event record
|
||||
// or fetched by `getOne` on the collection URI it carries — it is not a listed resource.
|
||||
resource: 'events',
|
||||
visibility: 'public',
|
||||
};
|
||||
|
||||
const bindings: RecordBindings = {
|
||||
event: eventBinding,
|
||||
meetingPoint: meetingPointBinding,
|
||||
profile: profileBinding,
|
||||
participation: participationBinding,
|
||||
};
|
||||
|
||||
/**
|
||||
* The resource configuration handed to the package's providers, keyed by the binding
|
||||
* resource names. `types` only — see the file header for why never `shapeTreeUri`.
|
||||
*/
|
||||
const APODS_RESOURCES = {
|
||||
events: { types: 'as:Event' },
|
||||
meetingPoints: { types: 'as:Event' },
|
||||
profiles: { types: 'as:Profile' },
|
||||
};
|
||||
|
||||
/** The platform context: defines the `as:`/`apods:` prefixes and is verified round-tripping on a real pod. */
|
||||
const JSON_CONTEXT = 'https://activitypods.org/context.json';
|
||||
|
||||
export const activitypods: RefineBackend = {
|
||||
name: 'activitypods',
|
||||
build() {
|
||||
// The SAME config the AuthGate flow resolves (runtime config is the shared
|
||||
// source of truth — T04.d): one clientId/redirect for both halves, so the
|
||||
// session the OIDC flow establishes is the session the data provider reads.
|
||||
const authConfig = resolveActivityPodsAuthConfig(window.location.origin);
|
||||
const apodsAuth = createApodsAuthProvider({
|
||||
clientId: authConfig.clientId,
|
||||
redirectUri: authConfig.redirectUri,
|
||||
// Disabled deliberately: the construction-time status timer leaks (usage
|
||||
// declaration, friction 1). Festipod drives getAppStatus() itself.
|
||||
appStatusCheckInterval: false,
|
||||
});
|
||||
return {
|
||||
authProvider: apodsAuth,
|
||||
dataProvider: createApodsDataProvider({
|
||||
authProvider: apodsAuth,
|
||||
resources: APODS_RESOURCES,
|
||||
jsonContext: JSON_CONTEXT,
|
||||
}),
|
||||
liveProvider: createApodsLiveProvider({
|
||||
authProvider: apodsAuth,
|
||||
resources: APODS_RESOURCES,
|
||||
jsonContext: JSON_CONTEXT,
|
||||
}),
|
||||
};
|
||||
},
|
||||
bindings,
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* The backend registry — which Refine backends exist, and which one this page runs.
|
||||
*
|
||||
* ONE VARIANT PER PAGE, NOT A SWITCH. A backend is selected by the URL — the
|
||||
* default one at the root, another through a path prefix naming it (`variant.ts`)
|
||||
* — so a page runs exactly one backend for its whole life, and changing backend
|
||||
* is a link that reloads. Nothing here hands the app a way to switch in session,
|
||||
* deliberately: the reload is what discards the previous backend's cache and
|
||||
* session instead of code that would have to undo them.
|
||||
*
|
||||
* NAMES ARE THE URL PREFIXES, so a backend must never be named after a top-level
|
||||
* route (`events`, `profile`, `home`, `settings`, `users`) — registering one would
|
||||
* turn that route into a variant switch. Nothing checks it: the names are ours.
|
||||
*
|
||||
* THE DEFAULT IS ACTIVITYPODS, per the directive of 2026-10-09 (concept
|
||||
* `data-layer`, `brief_2026-10-09_activitypods-first-backend`). The registry holds
|
||||
* exactly one backend until the suspended NextGraph+polyfill one resumes; the seam
|
||||
* around it (selection, mounting, the binding contract) exists for that second.
|
||||
*
|
||||
* WHY THE FIRST ONE WAS WITHDRAWN. `@ng-org/refine-providers` was wired and
|
||||
* measured, and it works as plain CRUD over SHEX shapes. It was withdrawn because
|
||||
* it is not the target: ActivityPods was made the sole priority, and hardening the
|
||||
* other one meanwhile buys nothing.
|
||||
*
|
||||
* NOT because it was incapable — an earlier version of this comment said so, and
|
||||
* was wrong. It blamed the absence of an inbox for the participant count, having
|
||||
* misread the model: every Participation is readable by everyone, only the link
|
||||
* to a person is protected, so counting them is ordinary CRUD. See `types.ts`.
|
||||
* What the measurements did establish is narrower and still worth knowing: the
|
||||
* count must not be an incremented field, because each write would derive from a
|
||||
* read of the previous one and race itself.
|
||||
*
|
||||
* The worked implementation is not in this branch's history: it lives on branch
|
||||
* `festipod-refine` (`providers/nextgraph.ts`, up to the commit that withdrew it),
|
||||
* and is the reference for the shape of the one that resumes.
|
||||
*
|
||||
* WITH NO BACKEND the app runs in demo mode: `FestipodDataProvider` has nothing
|
||||
* to read a connected wallet through, and says so rather than mounting a data
|
||||
* path that would return nothing.
|
||||
*/
|
||||
|
||||
import { resolveVariant } from '../variant';
|
||||
import { activitypods } from './activitypods';
|
||||
import type { RefineBackend } from './types';
|
||||
|
||||
export type { RecordBinding, RecordBindings, RefineBackend } from './types';
|
||||
export { resourceNames } from './types';
|
||||
|
||||
/** The default first: `initialBackendName` falls back to the first registered name. */
|
||||
const BACKENDS: Record<string, RefineBackend> = { activitypods };
|
||||
|
||||
/** Every registered backend — what the variant links offer. */
|
||||
export function listBackends(): RefineBackend[] {
|
||||
return Object.values(BACKENDS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Look a backend up by name. An unknown name throws rather than falling back:
|
||||
* running against a different store than the one asked for is worse than
|
||||
* failing, because nothing downstream would look wrong.
|
||||
*/
|
||||
export function getBackend(name: string): RefineBackend {
|
||||
const backend = BACKENDS[name];
|
||||
if (!backend) {
|
||||
const known = Object.keys(BACKENDS).join(', ') || '(none registered)';
|
||||
throw new Error(`Unknown Refine backend "${name}" — known: ${known}`);
|
||||
}
|
||||
return backend;
|
||||
}
|
||||
|
||||
/**
|
||||
* The backend this page runs, read from the URL, or `undefined` when none is
|
||||
* registered.
|
||||
*
|
||||
* A prefix is only ever matched against a REGISTERED name, so an unknown one is
|
||||
* not a request that could fail — it is an ordinary path, and it lands on the
|
||||
* default variant. Which is the right reading: `/events/42` names no backend.
|
||||
*/
|
||||
export function initialBackendName(): string | undefined {
|
||||
const names = Object.keys(BACKENDS);
|
||||
return resolveVariant(window.location.pathname, names).backend ?? names[0];
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* The contract a Refine BACKEND must satisfy to serve Festipod.
|
||||
*
|
||||
* Refine's own `DataProvider` / `AuthProvider` / `LiveProvider` already abstract
|
||||
* the transport, so they are not what this file adds. What they do NOT abstract
|
||||
* is everything around them, and each item below exists because the Events
|
||||
* vertical ran into it against `@ng-org/refine-providers` — none of it is
|
||||
* anticipated:
|
||||
*
|
||||
* - **Configuration.** `@ng-org` takes SHEX shape types; `@activitypods` takes
|
||||
* `resources: { events: { shapeTreeUri } }` plus a `jsonContext`. There is no
|
||||
* common shape, so building the providers belongs to the backend.
|
||||
* - **Record shape.** A record's fields are the backend's vocabulary. `@ng-org`
|
||||
* yields `fp:` predicates with `@id` / `@graph` / `@type`; ActivityPods yields
|
||||
* ActivityStreams JSON-LD. So domain mapping belongs to the backend too.
|
||||
* - **Two identifiers.** Refine addresses a record by one id, and the app's
|
||||
* entities reference each other by another. Under `@ng-org` those differ: the
|
||||
* address is the document NURI (`@graph`), while a participation points at the
|
||||
* subject IRI (`@id`). Collapsing them silently breaks the references.
|
||||
* - **Update semantics.** `@ng-org`'s `update` REPLACES the record, so a partial
|
||||
* write drops every field it omits. A backend that patches would not need the
|
||||
* read-merge the app does today, so the app has to be told which it is.
|
||||
*
|
||||
* A backend that cannot answer one of these cannot be added by guessing: the
|
||||
* answer is a fact about that backend, to be read from its contract or measured.
|
||||
*/
|
||||
|
||||
import type { AuthProvider, BaseKey, DataProvider, LiveProvider } from '@refinedev/core';
|
||||
import type {
|
||||
FpEventData,
|
||||
FpMeetingPointData,
|
||||
FpParticipationData,
|
||||
FpUserData,
|
||||
} from '../../data/types';
|
||||
|
||||
/** How one entity's records translate to and from the app's domain type. */
|
||||
export interface RecordBinding<T extends { id: string }> {
|
||||
/**
|
||||
* The key Refine's `update` / `getOne` address this record by. NOT necessarily
|
||||
* the domain id — see "Two identifiers" above.
|
||||
*/
|
||||
recordKey(record: unknown): BaseKey;
|
||||
/** The id the APP and its other entities use to reference this record. */
|
||||
domainId(record: unknown): string;
|
||||
/** Read a backend record into the app's domain type. */
|
||||
toDomain(record: unknown): T;
|
||||
/**
|
||||
* The values to send on create, and on update when the backend REPLACES: the
|
||||
* complete field set, because anything omitted is dropped.
|
||||
*/
|
||||
toValues(data: Omit<T, 'id'>): Record<string, unknown>;
|
||||
/**
|
||||
* The values to send when the backend PATCHES — only the fields that changed,
|
||||
* so a concurrent edit elsewhere in the record survives. Never called when
|
||||
* `updateReplaces` is true.
|
||||
*/
|
||||
toPatch(updates: Partial<T>): Record<string, unknown>;
|
||||
/**
|
||||
* Whether a raw record IS an instance of this binding's entity — the
|
||||
* container-discrimination case. On ActivityPods, events and meeting points
|
||||
* share the `as:Event` container and the presence of `as:context` is the only
|
||||
* distinction (the container does not discriminate, and Refine's in-memory
|
||||
* filters are value-based, not presence-based). The vocabulary lives here,
|
||||
* in the binding, so the reader can split a raw list without learning the
|
||||
* predicate. Absent means "every record of the resource is an instance".
|
||||
*/
|
||||
isInstance?(record: unknown): boolean;
|
||||
/**
|
||||
* The embedded-collection read, for entities whose records ARRIVE inside
|
||||
* another record: an event's `apods:attendees` collection travels with the
|
||||
* event record — its members dereferenced when the reader may read them, as
|
||||
* bare references otherwise, and the collection URI alone when not embedded.
|
||||
* Returns the members plus the total when known (a reader that may not
|
||||
* dereference members can still hold a `totalItems`), or `undefined` when
|
||||
* the record carries no readable collection. The reader stamps the host
|
||||
* record's id onto each mapped member — a member has no link back.
|
||||
*/
|
||||
membersOf?(record: unknown): { members: unknown[]; total?: number } | undefined;
|
||||
/** True when `update` replaces the record rather than patching it. */
|
||||
readonly updateReplaces: boolean;
|
||||
/**
|
||||
* Which Refine resource these records live under. The NAME is the backend's,
|
||||
* not the app's: `@ng-org` keys `shapeTypes` by it, `@activitypods` keys
|
||||
* `resources` by it, and nothing says the two must agree on a spelling.
|
||||
*/
|
||||
readonly resource: string;
|
||||
/**
|
||||
* WHO MUST BE ABLE TO READ these records. Festipod's confidentiality model is
|
||||
* per-entity (concept `functional-domain`), and each backend enforces it its own
|
||||
* way — NextGraph by placing a document in a store, ActivityPods by shape trees
|
||||
* and ACLs. Refine itself has NO notion of it, so the intent is declared here
|
||||
* and the backend is what honours it.
|
||||
*
|
||||
* Naming it does not make it enforced: the NextGraph data provider currently
|
||||
* reads across every graph it can see, so this records the INTENT and marks
|
||||
* where a backend has to close the gap.
|
||||
*/
|
||||
readonly visibility: 'public' | 'protected' | 'private';
|
||||
}
|
||||
|
||||
/**
|
||||
* The entities the app binds — one key per domain type it persists, all four
|
||||
* required. A backend that cannot carry one of them cannot serve the app, so
|
||||
* there is nothing optional here to let a half-backend register quietly.
|
||||
*
|
||||
* `Friendship` is deliberately absent: it has no shape and no persistence
|
||||
* anywhere in this project (concept `data-layer`, `knowledge_entities`).
|
||||
*/
|
||||
export interface RecordBindings {
|
||||
event: RecordBinding<FpEventData>;
|
||||
profile: RecordBinding<FpUserData>;
|
||||
participation: RecordBinding<FpParticipationData>;
|
||||
meetingPoint: RecordBinding<FpMeetingPointData>;
|
||||
}
|
||||
|
||||
/** Everything Festipod needs from one Refine backend. */
|
||||
export interface RefineBackend {
|
||||
/** Short, meaning-bearing name — used in logs and, later, in a selector. */
|
||||
readonly name: string;
|
||||
/**
|
||||
* Build this backend's Refine providers, with its own configuration. Called
|
||||
* ONCE: a new provider identity makes Refine re-run every query behind it.
|
||||
*/
|
||||
build(): {
|
||||
dataProvider: DataProvider;
|
||||
authProvider?: AuthProvider;
|
||||
liveProvider?: LiveProvider;
|
||||
};
|
||||
/** One binding per entity the app persists. */
|
||||
readonly bindings: RecordBindings;
|
||||
}
|
||||
|
||||
/**
|
||||
* HOW THE PARTICIPANT COUNT IS OBTAINED — a domain rule, not a backend capability.
|
||||
*
|
||||
* `participantCount` is the number of participants, and every Participation is
|
||||
* READABLE BY EVERYONE: what is protected is the link to a person, not the
|
||||
* record. Only a connection can read the profile a participation points at and
|
||||
* recognise who it is. So the count is obtained by COUNTING the participations of
|
||||
* an event — no filtering by visibility, because none is needed (concept
|
||||
* `data-layer`, brief_2026-07-20_attendance-set-model).
|
||||
*
|
||||
* This means NO backend capability is required for it: counting records is CRUD.
|
||||
* An earlier version of this comment claimed the opposite — that an inbox was
|
||||
* needed, and that a CRUD-only backend therefore could not carry the model. That
|
||||
* was a misreading of the brief's "no filtering", and it is wrong. The inbox this
|
||||
* app already uses (`src/shared/data/registration.ts`) is how a sign-up is
|
||||
* DELIVERED to the event's owner so the owner can maintain the aggregate; it is
|
||||
* one implementation of the count, not a precondition for having one.
|
||||
*
|
||||
* What IS a rule, and what the race measured here proved: the count must not be
|
||||
* an INCREMENTED field. Reading a stored counter out of a list, adding one and
|
||||
* writing it back derives each write from a read of the previous one, which
|
||||
* races itself through any data provider — and the project's brief declares that
|
||||
* mutated counter superseded.
|
||||
*/
|
||||
|
||||
/** Every resource name a backend serves, derived from its bindings. */
|
||||
export function resourceNames(backend: RefineBackend): string[] {
|
||||
return [...new Set(Object.values(backend.bindings).map((b) => b.resource))];
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { resolveVariant } from './variant';
|
||||
|
||||
// The names a registry could hold. `nextgraph` is the one variant planned beside
|
||||
// the default; `events` is NOT a backend and appears here only to prove an
|
||||
// ordinary route is never mistaken for a prefix.
|
||||
const KNOWN = ['activitypods', 'nextgraph'];
|
||||
|
||||
// THE CASE THAT IS TRUE TODAY, and the one most likely to be left untested: the
|
||||
// registry is empty, so every URL resolves to the default variant.
|
||||
test('no registered backend — every path is the default variant', () => {
|
||||
expect(resolveVariant('/', [])).toEqual({ backend: undefined, basePath: '' });
|
||||
expect(resolveVariant('/events/42', [])).toEqual({ backend: undefined, basePath: '' });
|
||||
expect(resolveVariant('/nextgraph', [])).toEqual({ backend: undefined, basePath: '' });
|
||||
});
|
||||
|
||||
// The normal case once a registry exists: the root serves the DEFAULT variant, and
|
||||
// `undefined` is what says "the default", never "none".
|
||||
test('the root is the default variant', () => {
|
||||
expect(resolveVariant('/', KNOWN)).toEqual({ backend: undefined, basePath: '' });
|
||||
});
|
||||
|
||||
test('an ordinary route is the default variant, not a prefix', () => {
|
||||
expect(resolveVariant('/events', KNOWN)).toEqual({ backend: undefined, basePath: '' });
|
||||
expect(resolveVariant('/events/42/participants', KNOWN)).toEqual({
|
||||
backend: undefined,
|
||||
basePath: '',
|
||||
});
|
||||
expect(resolveVariant('/profile/edit', KNOWN)).toEqual({ backend: undefined, basePath: '' });
|
||||
});
|
||||
|
||||
test('a registered name as first segment selects that variant', () => {
|
||||
expect(resolveVariant('/nextgraph', KNOWN)).toEqual({
|
||||
backend: 'nextgraph',
|
||||
basePath: '/nextgraph',
|
||||
});
|
||||
});
|
||||
|
||||
// The prefix carries through to every route under it — this is the shape the
|
||||
// router will have to strip.
|
||||
test('the prefix holds over a deep path', () => {
|
||||
expect(resolveVariant('/nextgraph/events/42/participants', KNOWN)).toEqual({
|
||||
backend: 'nextgraph',
|
||||
basePath: '/nextgraph',
|
||||
});
|
||||
});
|
||||
|
||||
// A name that is registered but matched only as a LATER segment is an event id or
|
||||
// a user id that happens to spell a backend's name, never a variant switch.
|
||||
test('a registered name deeper in the path is not a prefix', () => {
|
||||
expect(resolveVariant('/events/nextgraph', KNOWN)).toEqual({
|
||||
backend: undefined,
|
||||
basePath: '',
|
||||
});
|
||||
});
|
||||
|
||||
test('an unknown first segment falls back to the default variant', () => {
|
||||
expect(resolveVariant('/whatever', KNOWN)).toEqual({ backend: undefined, basePath: '' });
|
||||
});
|
||||
|
||||
// Shapes a browser or a hand-typed link really produces.
|
||||
test('empty, bare and repeated slashes resolve without throwing', () => {
|
||||
expect(resolveVariant('', KNOWN)).toEqual({ backend: undefined, basePath: '' });
|
||||
expect(resolveVariant('//nextgraph', KNOWN)).toEqual({
|
||||
backend: 'nextgraph',
|
||||
basePath: '/nextgraph',
|
||||
});
|
||||
expect(resolveVariant('/nextgraph/', KNOWN)).toEqual({
|
||||
backend: 'nextgraph',
|
||||
basePath: '/nextgraph',
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Which provider set the current URL selects.
|
||||
*
|
||||
* Festipod ships as ONE build serving SEVERAL variants, one per provider set, at
|
||||
* the same origin. The default variant lives at the root; another is reached
|
||||
* through a path prefix naming it. Switching is a plain link, so it costs a full
|
||||
* page load — which is the point: the React tree, Refine's cache and the previous
|
||||
* provider's session all go away, and nothing has to be migrated or torn down.
|
||||
* Losing access to the data of the variant being left is accepted.
|
||||
*
|
||||
* WHY NOT A BUILD-TIME CONSTANT. An earlier version of this read a build-injected
|
||||
* `__FESTIPOD_BACKEND__`, and its comment claimed `build.ts` defined it. Nothing
|
||||
* ever did — and it could not have worked here anyway: `bun run start` serves from
|
||||
* `src/`, so `build.ts`'s `define` block never reaches what the deployment runs
|
||||
* (concept `tech-stack`, `knowledge_build-pipeline`). A URL needs no injection, is
|
||||
* the same in dev and in production, and is linkable — which is what the switch is.
|
||||
*
|
||||
* NAMES ARE THE PREFIXES, so a backend must never be named after a top-level route
|
||||
* (`events`, `profile`, `home`, `settings`, `users`): registering one would turn
|
||||
* that route into a variant switch. The registry owns those names, so this is a
|
||||
* naming constraint rather than a check — see `providers/index.ts`.
|
||||
*/
|
||||
|
||||
/** The provider set a URL names, and the prefix that carries it. */
|
||||
export interface Variant {
|
||||
/** The backend named by the URL, or `undefined` to mean "the default one". */
|
||||
backend: string | undefined;
|
||||
/**
|
||||
* What every app path is prefixed with under this variant — `''` for the
|
||||
* default. The router has to strip it on parse and restore it on navigate; it
|
||||
* does not yet, because no URL carries a prefix while one backend is registered.
|
||||
*/
|
||||
basePath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the variant out of a pathname. Pure, so the whole rule is one test away.
|
||||
*
|
||||
* `known` is the registered backend names. Matching only against those is what
|
||||
* keeps an ordinary route (`/events`) from being read as a variant prefix.
|
||||
*/
|
||||
export function resolveVariant(pathname: string, known: readonly string[]): Variant {
|
||||
const firstSegment = pathname.replace(/^\/+/, '').split('/')[0] ?? '';
|
||||
if (firstSegment && known.includes(firstSegment)) {
|
||||
return { backend: firstSegment, basePath: `/${firstSegment}` };
|
||||
}
|
||||
return { backend: undefined, basePath: '' };
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* The local ActivityPods stack, driven over real HTTP — the instrument the
|
||||
* `@e2e-activitypods` smoke layer uses.
|
||||
*
|
||||
* WHAT THIS IS FOR. The suspended NextGraph suites (@data/@e2e/@multibrowser)
|
||||
* sign into a broker through a wallet; the ActivityPods stack is a different
|
||||
* thing altogether: a Pod provider (signup/login + LDP), the Festipod backend
|
||||
* (the on-behalf app), and the app served against them. This module is the
|
||||
* thin client the smoke scenarios speak to — real `fetch` calls, no mock, no
|
||||
* SDK. It validates FESTIPOD's side of the stock platform, never the platform's
|
||||
* own code (concept `bdd-testing`, `rule_tests-validate-festipod-not-the-sdk`).
|
||||
*
|
||||
* THE LOCAL STACK. Its reproducible setup is the concept note
|
||||
* `.project/concepts/data-layer/cookbook_local-activitypods-platform.md`
|
||||
* (pod provider on :3000, Festipod backend on :3003, redis remapped to :6390).
|
||||
* The values come from the same environment variables the app and the backend
|
||||
* read, with the cookbook's defaults as fallback.
|
||||
*
|
||||
* This module is loaded by Cucumber (Node) and has NO import-time side effects:
|
||||
* configuration is resolved lazily, at call time, once `.env` has been loaded.
|
||||
*/
|
||||
|
||||
/** A JSON-LD record as returned by the Pod or the backend: predicates verbatim. */
|
||||
export type JsonRecord = Record<string, unknown>;
|
||||
|
||||
/** Where the local stack lives. */
|
||||
export interface ApodsStackConfig {
|
||||
podProviderUrl: string;
|
||||
backendUrl: string;
|
||||
}
|
||||
|
||||
function stripTrailingSlash(url: string): string {
|
||||
return url.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the stack's URLs from the environment, falling back to the cookbook's
|
||||
* local defaults. Resolved per call so it sees the values `.env` sets.
|
||||
*/
|
||||
export function apodsStackConfig(): ApodsStackConfig {
|
||||
return {
|
||||
podProviderUrl: stripTrailingSlash(process.env.VITE_POD_PROVIDER_URL?.trim() || 'http://localhost:3000'),
|
||||
backendUrl: stripTrailingSlash(process.env.VITE_BACKEND_URL?.trim() || 'http://localhost:3003'),
|
||||
};
|
||||
}
|
||||
|
||||
/** An authenticated Pod session, plus the actor references the flows need. */
|
||||
export interface ApodsSession {
|
||||
webId: string;
|
||||
token: string;
|
||||
/** The user's own outbox — where a join/leave activity is posted. */
|
||||
outbox: string;
|
||||
/** The user's storage root — where a resource is created. */
|
||||
storage: string;
|
||||
}
|
||||
|
||||
/** The platform context: defines the `as:`/`apods:` prefixes the Pod round-trips. */
|
||||
const JSON_CONTEXT = 'https://activitypods.org/context.json';
|
||||
const AS_CONTEXT = 'https://www.w3.org/ns/activitystreams';
|
||||
|
||||
function asRecord(value: unknown): JsonRecord | undefined {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as JsonRecord) : undefined;
|
||||
}
|
||||
|
||||
function firstEntry(value: unknown): unknown {
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
}
|
||||
|
||||
/** A linked value in AS2 form: a bare IRI string, or an object carrying id/@id. */
|
||||
export function linkId(value: unknown): string | undefined {
|
||||
if (typeof value === 'string') return value;
|
||||
const o = asRecord(value);
|
||||
return o ? (typeof o['id'] === 'string' ? o['id'] : typeof o['@id'] === 'string' ? o['@id'] : undefined) : undefined;
|
||||
}
|
||||
|
||||
/** The `as:context` link of a record — what makes an `as/Event` a MEETING point. */
|
||||
export function contextLink(record: JsonRecord): string | undefined {
|
||||
return linkId(record['as:context']);
|
||||
}
|
||||
|
||||
/** The `apods:attendees` collection URI carried by a meeting point record. */
|
||||
export function attendeesCollectionUri(record: JsonRecord): string | undefined {
|
||||
return linkId(firstEntry(record['apods:attendees']));
|
||||
}
|
||||
|
||||
/** A record's own identifier — `id` or `@id`, whichever is present. */
|
||||
export function recordUri(record: JsonRecord): string | undefined {
|
||||
return linkId(record['id'] ?? record['@id']);
|
||||
}
|
||||
|
||||
async function request(
|
||||
url: string,
|
||||
init: RequestInit & { session?: ApodsSession } = {},
|
||||
): Promise<{ status: number; headers: Headers; text: string }> {
|
||||
const { session, ...rest } = init;
|
||||
const headers = new Headers(rest.headers);
|
||||
if (!headers.has('Accept')) headers.set('Accept', 'application/ld+json');
|
||||
if (session) headers.set('Authorization', `Bearer ${session.token}`);
|
||||
const response = await fetch(url, { ...rest, headers, signal: AbortSignal.timeout(15000) });
|
||||
return { status: response.status, headers: response.headers, text: await response.text() };
|
||||
}
|
||||
|
||||
/** Is the URL answering? Returns the HTTP status, or 0 when unreachable. */
|
||||
export async function urlStatus(url: string): Promise<number> {
|
||||
try {
|
||||
return (await request(url, { method: 'GET' })).status;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** Sign in to the local Pod provider and read the actor's outbox/storage.
|
||||
* On a fresh stack the account does not exist yet: `account.not-found` falls back
|
||||
* to the cookbook signup, then logs in — the smoke stays reproducible from a wipe. */
|
||||
export async function podLogin(
|
||||
config: ApodsStackConfig,
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<ApodsSession> {
|
||||
let login = await request(`${config.podProviderUrl}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
if (login.status === 401 && login.text.includes('account.not-found')) {
|
||||
const signup = await request(`${config.podProviderUrl}/auth/signup`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, email: `${username}@test.com`, password, name: username, 'schema:knowsLanguage': 'en' }),
|
||||
});
|
||||
if (signup.status !== 200) {
|
||||
throw new Error(`Pod signup failed: HTTP ${signup.status} ${signup.text.slice(0, 200)}`);
|
||||
}
|
||||
login = await request(`${config.podProviderUrl}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
}
|
||||
if (login.status !== 200) {
|
||||
throw new Error(`Pod login failed: HTTP ${login.status} ${login.text.slice(0, 200)}`);
|
||||
}
|
||||
const parsed = JSON.parse(login.text) as { token: string; webId: string };
|
||||
const partial: ApodsSession = { webId: parsed.webId, token: parsed.token, outbox: '', storage: '' };
|
||||
const actor = await readResource(partial, parsed.webId);
|
||||
const outbox = linkId(actor['outbox']);
|
||||
const storage = linkId(actor['pim:storage']);
|
||||
if (!outbox || !storage) {
|
||||
throw new Error(`Actor ${parsed.webId} carries no outbox or storage`);
|
||||
}
|
||||
return { ...partial, outbox, storage };
|
||||
}
|
||||
|
||||
/** GET a resource and parse it as a JSON-LD record (no session → anonymous read). */
|
||||
export async function readResource(session: ApodsSession | undefined, uri: string): Promise<JsonRecord> {
|
||||
const response = await request(uri, { session });
|
||||
if (response.status !== 200) {
|
||||
throw new Error(`Read ${uri} failed: HTTP ${response.status} ${response.text.slice(0, 200)}`);
|
||||
}
|
||||
return JSON.parse(response.text) as JsonRecord;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create one LDP resource in the session's storage, with the platform context
|
||||
* so a real Pod expands the `as:`/`apods:` CURIE types. Returns its Location.
|
||||
*/
|
||||
export async function createResource(
|
||||
session: ApodsSession,
|
||||
body: JsonRecord,
|
||||
): Promise<string> {
|
||||
const response = await request(session.storage, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/ld+json' },
|
||||
body: JSON.stringify({ '@context': JSON_CONTEXT, ...body }),
|
||||
session,
|
||||
});
|
||||
const location = response.headers.get('Location');
|
||||
if ((response.status !== 201 && response.status !== 200) || !location) {
|
||||
throw new Error(`Create in ${session.storage} failed: HTTP ${response.status} ${response.text.slice(0, 200)}`);
|
||||
}
|
||||
return location;
|
||||
}
|
||||
|
||||
/** Post an activity to the session's own outbox (the app's join/leave path). */
|
||||
export async function postOutboxActivity(session: ApodsSession, type: string, object: unknown, to?: string): Promise<void> {
|
||||
const activity: JsonRecord = { '@context': AS_CONTEXT, type, actor: session.webId, object };
|
||||
if (to) activity['to'] = to;
|
||||
const response = await request(session.outbox, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/ld+json' },
|
||||
body: JSON.stringify(activity),
|
||||
session,
|
||||
});
|
||||
if (response.status !== 200 && response.status !== 201) {
|
||||
throw new Error(`Outbox ${type} post failed: HTTP ${response.status} ${response.text.slice(0, 200)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The members of an `apods:attendees` collection, read by `getOne` on the
|
||||
* collection URI. Returns the raw member entries (bare URIs when the reader
|
||||
* may not dereference them) — a member the reader cannot resolve is a state
|
||||
* the app model needs, so it is reported, never dropped.
|
||||
*/
|
||||
export async function readAttendees(session: ApodsSession, collectionUri: string): Promise<unknown[]> {
|
||||
const response = await request(collectionUri, { session });
|
||||
if (response.status !== 200) {
|
||||
throw new Error(`Read collection ${collectionUri} failed: HTTP ${response.status} ${response.text.slice(0, 200)}`);
|
||||
}
|
||||
const record = JSON.parse(response.text) as JsonRecord;
|
||||
const raw = record['items'] ?? record['as:items'];
|
||||
if (Array.isArray(raw)) return raw;
|
||||
return raw === undefined || raw === null ? [] : [raw];
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { pool } from './browserPool';
|
||||
// The path the app asks the SDK to fetch its wallet from — ONE constant, shared
|
||||
// with the app, so the harness server and the bundle can never disagree on it.
|
||||
import { SHARED_WALLET_FILE_URL } from '../utils/sharedWallet';
|
||||
import { apodsStackConfig, urlStatus } from './activitypodsStack';
|
||||
|
||||
setDefaultTimeout(90000);
|
||||
|
||||
@@ -135,6 +136,74 @@ let useRealBroker = false;
|
||||
let appServerProcess: ChildProcess | null = null;
|
||||
let appPort = 0;
|
||||
|
||||
// ── ActivityPods local-stack smoke (@e2e-activitypods) ─────────────────────
|
||||
// A DIFFERENT app server and browser from the NG `@e2e` path: the ActivityPods
|
||||
// app variant needs no wallet and no broker — only the local stack's URLs, which
|
||||
// come from `activitypodsStack.apodsStackConfig()` (env, with the cookbook's
|
||||
// local defaults). Kept apart from the wallet-context launcher so the two layers
|
||||
// never share a profile or a session.
|
||||
let apodsAppServerProcess: ChildProcess | null = null;
|
||||
let apodsAppPort = 0;
|
||||
let apodsBrowser: Browser | null = null;
|
||||
|
||||
/** The app's own `/festipod-config.json`, used as the readiness probe. */
|
||||
async function waitForAppReady(url: string, timeoutMs: number): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
for (;;) {
|
||||
if ((await urlStatus(`${url}/festipod-config.json`)) === 200) return;
|
||||
if (Date.now() > deadline) {
|
||||
throw new Error(`ActivityPods app server did not become ready at ${url} within ${timeoutMs}ms`);
|
||||
}
|
||||
await new Promise<void>((resolve) => { setTimeout(resolve, 500); });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the real Festipod app server for the ActivityPods smoke and return its
|
||||
* URL — the same `bun src/index.ts` the NG `@e2e` path starts, on its own port
|
||||
* and with its own process group (so teardown can signal the whole tree).
|
||||
* Idempotent: the server and the Chromium instance are started once per run.
|
||||
*/
|
||||
async function ensureApodsApp(): Promise<string> {
|
||||
if (!apodsAppPort) {
|
||||
apodsAppPort = await new Promise<number>((resolve) => {
|
||||
const s = http.createServer();
|
||||
s.listen(0, '127.0.0.1', () => {
|
||||
const port = (s.address() as { port: number }).port;
|
||||
s.close(() => resolve(port));
|
||||
});
|
||||
});
|
||||
apodsAppServerProcess = spawn('bun', ['src/index.ts'], {
|
||||
env: {
|
||||
...process.env,
|
||||
PORT: String(apodsAppPort),
|
||||
NODE_ENV: 'production',
|
||||
// The app's runtime config comes from the server's env: point it at the
|
||||
// local stack the smoke talks to (`.env` carries none here).
|
||||
VITE_BACKEND_URL: apodsStackConfig().backendUrl,
|
||||
VITE_POD_PROVIDER_URL: apodsStackConfig().podProviderUrl,
|
||||
},
|
||||
stdio: 'pipe',
|
||||
cwd: process.cwd(),
|
||||
detached: true,
|
||||
});
|
||||
// Drain the pipes: an unread 64 KB stdio buffer would block the server mid-run.
|
||||
apodsAppServerProcess.stdout?.on('data', () => {});
|
||||
apodsAppServerProcess.stderr?.on('data', () => {});
|
||||
await waitForAppReady(`http://127.0.0.1:${apodsAppPort}`, 30000);
|
||||
console.log(`[ApodsE2E] App server on http://127.0.0.1:${apodsAppPort}`);
|
||||
}
|
||||
if (!apodsBrowser) {
|
||||
apodsBrowser = await chromium.launch({
|
||||
headless: true,
|
||||
executablePath: resolveChromePath(),
|
||||
args: LAUNCH_ARGS,
|
||||
});
|
||||
console.log('[ApodsE2E] Chromium launched');
|
||||
}
|
||||
return `http://127.0.0.1:${apodsAppPort}`;
|
||||
}
|
||||
|
||||
const WALLET_NAME = 'festipod-tests';
|
||||
const WALLET_PASSWORD = 'festipod-tests';
|
||||
|
||||
@@ -519,6 +588,7 @@ Before({ timeout: 60000 }, async function (this: FestipodWorld, scenario) {
|
||||
this.screenSourceContent = '';
|
||||
this.currentScreen = null;
|
||||
this.pageErrors = [];
|
||||
this.apods = {};
|
||||
|
||||
// Multi-browser scenarios drive their own isolated browsers via steps
|
||||
// (this.openBrowser). They must NOT get the legacy single shared page.
|
||||
@@ -637,6 +707,51 @@ Before({ timeout: 60000 }, async function (this: FestipodWorld, scenario) {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* ActivityPods local-stack smoke. Sits AFTER the generic Before (which reset
|
||||
* the world), so it only has to gate on the stack and, for `@browser` scenarios,
|
||||
* load the real app. The stack is REQUIRED — a smoke that silently skips when
|
||||
* the stack is down would prove nothing, so an unreachable stack fails here with
|
||||
* the cookbook pointer instead.
|
||||
*/
|
||||
Before({ timeout: 120000 }, async function (this: FestipodWorld, scenario) {
|
||||
const tags = scenario.pickle.tags.map(t => t.name);
|
||||
if (!tags.includes('@e2e-activitypods')) return;
|
||||
|
||||
const config = apodsStackConfig();
|
||||
const podStatus = await urlStatus(`${config.podProviderUrl}/.well-known/openid-configuration`);
|
||||
if (podStatus !== 200) {
|
||||
throw new Error(
|
||||
`Pod provider unreachable at ${config.podProviderUrl} (HTTP ${podStatus}). ` +
|
||||
'Start the local stack — concept `data-layer`, `cookbook_local-activitypods-platform.md`.',
|
||||
);
|
||||
}
|
||||
const backendStatus = await urlStatus(`${config.backendUrl}/app`);
|
||||
if (backendStatus !== 200) {
|
||||
throw new Error(
|
||||
`Festipod backend unreachable at ${config.backendUrl}/app (HTTP ${backendStatus}). ` +
|
||||
'Start the local stack — concept `data-layer`, `cookbook_local-activitypods-platform.md`.',
|
||||
);
|
||||
}
|
||||
|
||||
if (!tags.includes('@browser')) return;
|
||||
|
||||
const appUrl = await ensureApodsApp();
|
||||
this.page = await apodsBrowser!.newPage();
|
||||
const world = this;
|
||||
this.page.on('pageerror', (err) => {
|
||||
console.error('[Browser error]', err.message);
|
||||
world.pageErrors.push(`pageerror: ${err.message}`);
|
||||
});
|
||||
this.page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') {
|
||||
console.error('[Browser console]', msg.text());
|
||||
world.pageErrors.push(`console.error: ${msg.text()}`);
|
||||
}
|
||||
});
|
||||
this.apods.appUrl = appUrl;
|
||||
});
|
||||
|
||||
After({ timeout: 10000 }, async function (this: FestipodWorld, scenario) {
|
||||
if (scenario.result?.status === Status.FAILED) {
|
||||
this.attach(`Current route: ${this.currentRoute}`, 'text/plain');
|
||||
@@ -775,6 +890,13 @@ AfterAll(async function () {
|
||||
appServerProcess = null;
|
||||
await safe('appServer.stop', 10000, () => stopAppServer(child));
|
||||
}
|
||||
if (apodsAppServerProcess) {
|
||||
const child = apodsAppServerProcess;
|
||||
apodsAppServerProcess = null;
|
||||
await safe('apodsAppServer.stop', 10000, () => stopAppServer(child));
|
||||
}
|
||||
await safe('apodsBrowser.close', 30000, () => apodsBrowser?.close());
|
||||
apodsBrowser = null;
|
||||
await safe('freshBrowser.close', 30000, () => freshBrowser?.close());
|
||||
freshBrowser = null;
|
||||
pool.freshBrowser = null;
|
||||
|
||||
@@ -5,6 +5,21 @@ import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { renderScreen as renderUiScreen, unmountRender } from '../test-harness/renderHelper';
|
||||
import { pool, spawnContext, type NamedBrowser, type WalletModel } from './browserPool';
|
||||
import type { ApodsSession } from './activitypodsStack';
|
||||
|
||||
/**
|
||||
* The live HTTP state one `@e2e-activitypods` scenario accumulates against the
|
||||
* local Pod: the session it signed in with, and the URIs it created. Reset per
|
||||
* scenario by the Before hook.
|
||||
*/
|
||||
export interface ApodsScenarioState {
|
||||
session?: ApodsSession;
|
||||
/** The app server URL the `@browser` scenario loads (set by the hook). */
|
||||
appUrl?: string;
|
||||
anchorUri?: string;
|
||||
meetingPointUri?: string;
|
||||
collectionUri?: string;
|
||||
}
|
||||
|
||||
export interface FestipodWorld extends World {
|
||||
currentRoute: string;
|
||||
@@ -30,6 +45,10 @@ export interface FestipodWorld extends World {
|
||||
// (guards the "page blanche once connected" class of bug). Reset per scenario.
|
||||
pageErrors: string[];
|
||||
|
||||
// ActivityPods local-stack smoke (@e2e-activitypods): the live HTTP state a
|
||||
// scenario accumulates. Reset per scenario.
|
||||
apods: ApodsScenarioState;
|
||||
|
||||
// Multi-browser (named, isolated contexts) — for cross-browser wallet tests.
|
||||
// The wallet model (own vs shared) is an axis orthogonal to browser count.
|
||||
browsers: Map<string, NamedBrowser>;
|
||||
@@ -248,6 +267,9 @@ class CustomWorld extends World implements FestipodWorld {
|
||||
// Runtime errors emitted by the app page during the scenario (see interface).
|
||||
pageErrors: string[] = [];
|
||||
|
||||
// ActivityPods local-stack smoke state (see interface).
|
||||
apods: ApodsScenarioState = {};
|
||||
|
||||
// Multi-browser (named, isolated contexts)
|
||||
browsers: Map<string, NamedBrowser> = new Map();
|
||||
|
||||
|
||||
@@ -245,10 +245,10 @@ function ScopedHarness({ publicGraph, protectedGraph }: { publicGraph: Nuri; pro
|
||||
|
||||
// --- Mutations (app path: per-entity docs + reactivity) ---
|
||||
async joinEvent(eventId: string, userId: string) {
|
||||
await AD().joinEvent(eventId, userId);
|
||||
await AD().joinMeetingPoint(eventId, userId);
|
||||
},
|
||||
async leaveEvent(eventId: string, userId: string) {
|
||||
await AD().leaveEvent(eventId, userId);
|
||||
await AD().leaveMeetingPoint(eventId, userId);
|
||||
},
|
||||
|
||||
// --- Real app-path registration (T02.c) ----------------------------
|
||||
@@ -276,10 +276,10 @@ function ScopedHarness({ publicGraph, protectedGraph }: { publicGraph: Nuri; pro
|
||||
return { id, title };
|
||||
},
|
||||
async appJoinEvent(eventId: string, userId?: string) {
|
||||
await AD().joinEvent(eventId, userId);
|
||||
await AD().joinMeetingPoint(eventId, userId);
|
||||
},
|
||||
async appLeaveEvent(eventId: string, userId?: string) {
|
||||
await AD().leaveEvent(eventId, userId);
|
||||
await AD().leaveMeetingPoint(eventId, userId);
|
||||
},
|
||||
/** A LIVE current user id, resolved from the users set AT CALL TIME (not
|
||||
* frozen at bridge-build). Prefers the app context's principal; falls
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user