3 Commits

Author SHA1 Message Date
Sylvain Duchesne b09884d586 ActivityPods Solid-OIDC auth flow in AuthGate with pod provider picker and /login callback 2026-10-09 14:07:47 +02:00
Sylvain Duchesne e17398d86d T04.f: backend Festipod ActivityPods + dockerisation
- Ajoute la configuration et les services backend (app, actors, registration,
  attendees, contacts, catalog, core auth/nodeinfo/proxy/notification).
- Corrige le démarrage : désactive resourcesWithContainerPath pour éviter le
  conflit entre l'URI de l'acteur applicatif (/as/actor/app) et la route /app.
- Corrige l'URL du dépôt de shapes : https://shapes.activitypods.org/.
- Ajoute backend/Dockerfile et docker-compose.yml racine (frontend + backend).
- Met à jour .env.example racine avec les variables SEMAPPS_* et VITE_BACKEND_*.
- Gates verts : bunx tsc --noEmit (0 erreur) et bun test (50 pass).
2026-10-09 13:51:02 +02:00
Sylvain Duchesne e27cfa4e0a The ActivityPods backend registers as the default behind the seam 2026-10-09 13:37:28 +02:00
31 changed files with 1763 additions and 86 deletions
+43
View File
@@ -43,6 +43,49 @@ PORT=3000
# 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:5000
# 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}/app`).
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.
@@ -59,6 +59,7 @@ What must keep holding for this backend to serve the app.
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
+14
View File
@@ -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:5000
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
+25
View File
@@ -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"]
+18
View File
@@ -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
};
+62
View File
@@ -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
}
};
+6 -1
View File
@@ -10,19 +10,24 @@
"@rdfjs/data-model": "2.1.1",
"@semapps/activitypub": "1.2.7",
"@semapps/auth": "1.2.7",
"@semapps/crypto": "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",
"url-join": "^4.0.1"
},
"engines": {
+15
View File
@@ -32,15 +32,27 @@ importers:
'@semapps/ldp':
specifier: 1.2.7
version: 1.2.7(debug@4.4.3)(moleculer@0.14.36(debug@4.4.3)(ioredis@4.31.0))
'@semapps/mime-types':
specifier: 1.2.7
version: 1.2.7(moleculer@0.14.36(debug@4.4.3)(ioredis@4.31.0))
'@semapps/nodeinfo':
specifier: 1.2.7
version: 1.2.7(moleculer@0.14.36(debug@4.4.3)(ioredis@4.31.0))
'@semapps/ontologies':
specifier: 1.2.7
version: 1.2.7(moleculer@0.14.36(debug@4.4.3)(ioredis@4.31.0))
'@semapps/solid':
specifier: 1.2.7
version: 1.2.7(debug@4.4.3)(moleculer@0.14.36(debug@4.4.3)(ioredis@4.31.0))
'@semapps/sparql-endpoint':
specifier: 1.2.7
version: 1.2.7(moleculer@0.14.36(debug@4.4.3)(ioredis@4.31.0))
'@semapps/triplestore':
specifier: 1.2.7
version: 1.2.7(moleculer@0.14.36(debug@4.4.3)(ioredis@4.31.0))
'@semapps/webacl':
specifier: 1.2.7
version: 1.2.7(debug@4.4.3)(moleculer@0.14.36(debug@4.4.3)(ioredis@4.31.0))
dotenv-flow:
specifier: ^3.2.0
version: 3.3.0
@@ -59,6 +71,9 @@ importers:
moleculer-repl:
specifier: ^0.6.6
version: 0.6.6
sharp:
specifier: 0.31.3
version: 0.31.3
url-join:
specifier: ^4.0.1
version: 4.0.1
+51
View File
@@ -0,0 +1,51 @@
const urlJoin = require('url-join');
const { AppService } = require('@activitypods/app');
const CONFIG = require('../config/config');
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
}
};
+17
View File
@@ -0,0 +1,17 @@
const urlJoin = require('url-join');
const { PodCollectionsHandlerMixin } = require('@activitypods/app');
const CONFIG = require('../config/config');
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
}
};
+41
View File
@@ -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}`);
}
}
};
+17
View File
@@ -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 })
};
+53
View File
@@ -0,0 +1,53 @@
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 /': 'app.get'
},
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
}
};
+30
View File
@@ -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
}
};
+3
View File
@@ -0,0 +1,3 @@
const { ProxyService } = require('@semapps/crypto');
module.exports = ProxyService;
+97
View File
@@ -0,0 +1,97 @@
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');
},
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 = event['apods:attendees'];
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 = event['apods:attendees'];
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
});
}
}
}
};
+28
View File
@@ -0,0 +1,28 @@
services:
frontend:
build:
context: .
dockerfile: Dockerfile
ports:
- "3000: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:
- ./backend/.env
environment:
- SEMAPPS_HOME_URL=${SEMAPPS_HOME_URL:-http://localhost:3003}
- SEMAPPS_FRONT_URL=${SEMAPPS_FRONT_URL:-http://localhost:5000}
+19 -12
View File
@@ -4,6 +4,7 @@ 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 +38,7 @@ function AppContent() {
case 'home': return <HomeScreen />;
case 'events': return <EventsScreen />;
case 'create-event': return <CreateEventScreen />;
case 'login': return null;
case 'event-detail': return <EventDetailScreen />;
case 'update-event': return <UpdateEventScreen />;
case 'invite': return <InviteScreen />;
@@ -53,20 +55,25 @@ function AppContent() {
}
export function App() {
const backendName = initialBackendName();
const isNg = backendName === 'nextgraph';
const appTree = (
<FestipodDataProvider>
<RouterProvider>
<div className="app-container">
<AuthGate>
<AppContent />
</AuthGate>
<ToastContainer />
</div>
</RouterProvider>
</FestipodDataProvider>
);
return (
<ThemeProvider>
<NextGraphProvider>
<FestipodDataProvider>
<RouterProvider>
<div className="app-container">
<AuthGate>
<AppContent />
</AuthGate>
<ToastContainer />
</div>
</RouterProvider>
</FestipodDataProvider>
</NextGraphProvider>
{isNg ? <NextGraphProvider>{appTree}</NextGraphProvider> : appTree}
</ThemeProvider>
);
}
+151 -46
View File
@@ -1,83 +1,147 @@
/**
* 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`.
*
* 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 { useRouter, useNavigate } from './router';
import { initialBackendName } from '../shared/refine/providers';
import { getBackendUrl } from '../shared/utils/runtimeConfig';
import { buildActivityPodsAuthProvider, parseAuthFlowStage } from './auth/activityPodsAuth';
import { PodProviderPicker } from './auth/PodProviderPicker';
type ActivityPodsAuthState = 'checking' | 'authenticated' | 'unauthenticated' | 'error';
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 stage = parseAuthFlowStage(window.location.search);
try {
if (stage.stage === 'callback') {
const result = await provider.handleCallback();
if (cancelled) return;
setCurrentPrincipal(result.webId);
navigate(`/login?register_app=1&redirect=${encodeURIComponent(stage.redirectPath)}`);
return;
}
if (stage.stage === 'register_app') {
const session = provider.getSession();
const registration = session ? await provider.registerApp(session.webId) : undefined;
if (cancelled) return;
if (registration) {
if (session) setCurrentPrincipal(session.webId);
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);
if (!cancelled) setApState('authenticated');
} else {
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 +155,54 @@ 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') {
return (
<PodProviderPicker
authProvider={apAuthProvider!}
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}</>;
}
+111
View File
@@ -0,0 +1,111 @@
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;
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, onDemo }: PodProviderPickerProps) {
const { podProviderUrl } = resolveActivityPodsAuthConfig(window.location.origin);
const [customUrl, setCustomUrl] = useState('');
const startLogin = (issuer: string) => {
const params = new URLSearchParams(window.location.search);
const redirect = params.get('redirect') || '/home';
void authProvider.login({ issuer, redirect });
};
const defaultProviderHost = podProviderUrl ? safeHost(podProviderUrl) : undefined;
return (
<div
style={{
padding: 24,
display: 'flex',
flexDirection: 'column',
height: '100%',
minHeight: '100dvh',
}}
>
<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;
}
}
+73
View File
@@ -0,0 +1,73 @@
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 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/app');
expect(cfg.redirectUri).toBe('http://localhost:5000/login');
});
test('clientId falls back to origin/app when nothing is configured', () => {
setRuntimeConfig({});
const cfg = resolveActivityPodsAuthConfig('http://localhost:5000');
expect(cfg.clientId).toBe('http://localhost:5000/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/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' });
// A bare `?redirect` does not change the stage; the redirect path is only read
// when the user lands back from the Pod provider.
expect(parseAuthFlowStage('?redirect=/events')).toEqual({ stage: 'picker' });
});
+71
View File
@@ -0,0 +1,71 @@
import { authProvider as createActivityPodsAuthProvider } from '@activitypods/refine-providers';
import type { AuthProvider } from '@activitypods/refine-providers';
import { getRuntimeConfig } from '../../shared/utils/runtimeConfig';
/**
* Resolved ActivityPods auth configuration.
*
* `clientId` names the app description document the backend serves, as required
* by the Solid-OIDC flow. `redirectUri` is fixed to `${origin}/login` because the
* app renders its own callback route there.
*/
export interface ResolvedAuthConfig {
clientId: string;
redirectUri: string;
podProviderUrl: string | undefined;
}
/**
* Resolve the auth configuration from runtime values.
*
* - `backendClientId` wins when explicitly set.
* - Otherwise, `backendUrl` is used to derive `${backendUrl}/app`.
* - Otherwise, fall back to `${origin}/app` so local dev still typechecks and
* can be exercised without a backend.
*/
export function resolveActivityPodsAuthConfig(origin: string): ResolvedAuthConfig {
const cfg = getRuntimeConfig();
const backendClientId = cfg.backendClientId?.trim();
const backendUrl = cfg.backendUrl?.trim();
const clientId = backendClientId || (backendUrl ? `${backendUrl}/app` : `${origin}/app`);
return {
clientId,
redirectUri: `${origin}/login`,
podProviderUrl: cfg.podProviderUrl?.trim() || undefined,
};
}
/** 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,
});
}
/** 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' };
/**
* 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 preserved through the stages and defaults to `/home`.
*/
export function parseAuthFlowStage(search: string): AuthFlowStage {
const params = new URLSearchParams(search);
const redirectPath = params.get('redirect') || '/home';
if (params.has('code')) return { stage: 'callback', redirectPath };
if (params.has('register_app')) return { stage: 'register_app', redirectPath };
return { stage: 'picker' };
}
+27 -5
View File
@@ -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>;
+4 -1
View File
@@ -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 };
@@ -40,6 +41,7 @@ function parsePath(pathname: string): Route {
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' };
@@ -74,6 +76,7 @@ export function routeToPath(route: Route): string {
case 'home': return '/home';
case 'events': return '/events';
case 'create-event': return '/events/new';
case 'login': return '/login';
case 'event-detail': return `/events/${route.eventId}`;
case 'update-event': return `/events/${route.eventId}/edit`;
case 'invite': return `/events/${route.eventId}/invite`;
+12 -3
View File
@@ -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.
//
@@ -0,0 +1,302 @@
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: '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: '' });
expect(
event.toValues({ title: 'T', description: '', date: '', startTime: '09:00', location: '', participantCount: 0 }),
).toEqual({ name: 'T', content: '', location: '' });
});
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 in both its forms', () => {
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' },
organizer: '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: '',
});
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 organizer, not the unmapped fields', () => {
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: 'Café de la Place',
organizer: 'http://localhost:3000/alice',
'as:context': 'http://localhost:3000/alice/data/event-1',
});
});
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: '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: '',
});
});
+404
View File
@@ -0,0 +1,404 @@
/**
* 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), and Festipod drives `getAppStatus()` on its own
* schedule. The `clientId` names the app; the app description document it points to
* is Festipod's own to serve (a static file suffices) and lands with the Solid-OIDC
* flow, so until then the URL is derived from the origin this page runs on.
*/
import {
authProvider as createApodsAuthProvider,
dataProvider as createApodsDataProvider,
liveProvider as createApodsLiveProvider,
} from '@activitypods/refine-providers';
import type { FpEventData, FpMeetingPointData, FpParticipationData, FpUserData } from '../../data/types';
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);
}
/**
* 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 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'] = 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; `as:organizer` carries the host.
* 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'] = data.location;
if (data.hostId !== undefined) out['organizer'] = data.hostId;
if (data.eventId !== undefined) out['as:context'] = data.eventId;
return out;
}
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']) ?? '',
hostId: 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'] = 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',
);
}
const eventBinding: RecordBinding<FpEventData> = {
recordKey: recordId,
domainId: recordId,
toDomain: eventFromRecord,
toValues: (data) => eventValues(data),
toPatch: (updates) => eventValues(updates),
updateReplaces: false,
resource: 'events',
visibility: 'public',
};
const meetingPointBinding: RecordBinding<FpMeetingPointData> = {
recordKey: recordId,
domainId: recordId,
toDomain: meetingPointFromRecord,
toValues: (data) => meetingPointValues(data),
toPatch: (updates) => meetingPointValues(updates),
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,
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() {
const apodsAuth = createApodsAuthProvider({
// The app description document this names is Festipod's own to serve — a static
// file suffices — and lands with the Solid-OIDC flow; until then the URL is the
// one this page can already answer for.
clientId: `${window.location.origin}/client.json`,
// 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,
};
+11 -18
View File
@@ -1,11 +1,6 @@
/**
* The backend registry — which Refine backends exist, and which one this page runs.
*
* IT IS EMPTY ON PURPOSE, and that is the current state of the work rather than
* an oversight. Festipod must end up supporting SEVERAL Refine backends, and the
* seam for that is what this directory is: the contract in `types.ts`, the
* selection below, the mounting in `RefineProvider`. What is missing is backends.
*
* 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
@@ -17,10 +12,15 @@
* 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: what is coming is a Refine provider over NextGraph *plus
* the polyfill*, and hardening this one meanwhile buys nothing.
* 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
@@ -32,16 +32,7 @@
*
* 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 next one's shape.
*
* WHAT COMES NEXT, and why the seam stays: that NextGraph+polyfill provider, and
* `@activitypods/refine-providers`. Festipod has to support both, so the structure
* is kept and only the entry is gone.
*
* ADDING ONE is a file implementing `RefineBackend` plus a line here. What that
* file must answer is in `types.ts` and cannot be guessed: how its providers are
* configured, what its records look like, which key addresses a record versus
* which id the app references, and whether its `update` replaces or patches.
* 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
@@ -49,12 +40,14 @@
*/
import { resolveVariant } from '../variant';
import { activitypods } from './activitypods';
import type { RefineBackend } from './types';
export type { RecordBinding, RecordBindings, RefineBackend } from './types';
export { resourceNames } from './types';
const BACKENDS: Record<string, RefineBackend> = {};
/** 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[] {
+38
View File
@@ -0,0 +1,38 @@
/**
* Runtime configuration — values the server injects via `/festipod-config.json`.
*
* The browser bundle cannot read `process.env`, and `build.ts` only bakes in the
* shared-wallet material. Everything else the frontend needs at runtime lives
* here: backend URL, Solid-OIDC client id, and default Pod provider URL.
*
* `frontend.tsx` fetches the config endpoint and calls `setRuntimeConfig` before
* the app tree evaluates, so any module that imports this file reads the current
* values. Keeping the shape flat and env-driven lets dev/staging/prod differ
* without rebuilding.
*/
export interface RuntimeConfig {
/** URL of the Festipod backend as seen by the browser. */
backendUrl?: string;
/** Solid-OIDC `client_id`; when empty, derived as `${backendUrl}/app`. */
backendClientId?: string;
/** Default Pod provider URL; when empty, the picker asks the user. */
podProviderUrl?: string;
}
let runtimeConfig: RuntimeConfig = {};
/** Replace the runtime config. Called once by `frontend.tsx` before `App` loads. */
export function setRuntimeConfig(cfg: RuntimeConfig): void {
runtimeConfig = cfg;
}
/** The current runtime config. */
export function getRuntimeConfig(): RuntimeConfig {
return runtimeConfig;
}
/** URL of the Festipod backend, when configured. */
export function getBackendUrl(): string | undefined {
return runtimeConfig.backendUrl?.trim() || undefined;
}