@ofidj/generator-fidj 3.13.0 → 3.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -40,7 +40,7 @@ one.
40
40
 
41
41
  > Maintainers: `bin/create-fidj.cjs` is the same scaffolder without Yeoman, for
42
42
  > tooling that runs from a checkout and cannot install `yo` —
43
- > `scripts/local-stack.py` and `fidj-app`'s `create:local` use it.
43
+ > fidj-brain's `scripts/local-stack.py` and `fidj-app`'s `create:local` use it.
44
44
  > `test/parity.test.cjs` fails if either door ever gains an input the other
45
45
  > lacks. It is not an entry point to hand to anyone building an app.
46
46
 
@@ -87,14 +87,14 @@ build when one is supplied, the template's `@ofidj/node` range otherwise — and
87
87
  written to `APP_VERSION` and `app.config.json`. On Fidj's own apps it also shows
88
88
  the version `/v3/status` answers with, so the two can be compared without
89
89
  translation. Keep that range on the version this generator releases: the
90
- workspace's `scripts/check-versions.py` and this package's own suite both refuse
90
+ workspace's `scripts/check-versions.py` (in fidj-brain) and this package's own suite both refuse
91
91
  the mismatch.
92
92
 
93
93
  ## Real validation repository
94
94
 
95
95
  [mleweb](https://github.com/mlefree/mleweb) is the thin command-line validation fixture whose generated website is mlefree.com. Its `package.json` passes the original Mario GIF, welcome text and About/CV/contact links directly into this generator. No custom downstream renderer is required. GitHub CI runs the same generation/build path on Node 22/24 and uploads artifacts. Publishing the site to gh-pages, which is what mlefree.com serves, is a separate deliberate job: it runs on mleweb's `master` or on an explicit workflow dispatch, never from a version branch.
96
96
 
97
- Run `npm test` for scaffolding/CLI safety tests. Generated projects include TypeScript checking and HTTP integration tests for live authorization and privacy isolation. See the [local acceptance walkthrough](../LOCAL-DEVELOPMENT.md). Follow TDD: add/run a failing generator or generated-app test before implementation, make it green, then refactor and rerun regression checks.
97
+ Run `npm test` for scaffolding/CLI safety tests. Generated projects include TypeScript checking and HTTP integration tests for live authorization and privacy isolation. See the [local acceptance walkthrough](../fidj-brain/LOCAL-DEVELOPMENT.md). Follow TDD: add/run a failing generator or generated-app test before implementation, make it green, then refactor and rerun regression checks.
98
98
 
99
99
 
100
100
  ## Releasing to npm
@@ -178,7 +178,7 @@ A compatible issuer, REST API and registered callback without a fragment are pre
178
178
 
179
179
  ## Required service agreement
180
180
 
181
- [The workspace README](../README.md#entry-one-flow-the-same-everywhere) defines
181
+ [The workspace README](../fidj-brain/README.md#entry-one-flow-the-same-everywhere) defines
182
182
  the flow every Fidj sign-in surface follows, and this generator owns the
183
183
  implementation the others render. In short: the first screen asks for an email,
184
184
  a password, **Sign in** and **Create an account**, and gates neither; creating an
@@ -15,8 +15,8 @@
15
15
  "privacy:rehearse": "npm run build && node --test test/server.test.mjs"
16
16
  },
17
17
  "dependencies": {
18
- "@ofidj/entry": "^3.13.0",
19
- "@ofidj/node": "^3.13.0"
18
+ "@ofidj/entry": "^3.15.0",
19
+ "@ofidj/node": "^3.15.0"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@types/node": "^22.0.0",
@@ -32,6 +32,6 @@ function mountedHead(config) {
32
32
  export function renderContent(config) {
33
33
  // HTML is supplied by the developer at generation time, never by an app visitor.
34
34
  return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="referrer" content="no-referrer"><title>${escape(config.title)}</title><link rel="icon" href="${escape(config.favicon)}"><link rel="stylesheet" href="./main.css">${mountedHead(config)}</head><body>
35
- <header class="topbar"><a class="brand" href="#/content"><img src="${escape(config.logo)}" alt="">${escape(config.title)}</a><nav class="content-nav" id="app-nav" aria-label="App navigation" hidden></nav></header>
35
+ <header class="topbar"><a class="brand" href="#/content"><img src="${escape(config.logo)}" alt=""><span class="brand-name">${escape(config.title)}</span></a><nav class="content-nav" id="app-nav" aria-label="App navigation" hidden></nav></header>
36
36
  <main><template id="public-content"><section class="card public-content"><h1>${escape(config.welcome)}</h1><div>${config.content}</div></section></template><div id="app" aria-live="polite"></div><footer>Built with Fidj · Your choices belong to this app.</footer></main><script type="module" src="./main.js"></script></body></html>`;
37
37
  }
@@ -1,5 +1,5 @@
1
1
  import {agreementModel, signInErrorMessage, agreementRequired, agreementFromRefusal, verificationPending, pollVerification, rememberSignIn, forgetSignIn, signInHint, type SigninShape} from "@ofidj/entry";
2
- import {agreementScreen, bindAgreementScreen, bindPasswordReveal, acceptedAgreement, verificationWait, providerEntry, showEmailEntry, showVersionBadge, escape, masthead, highlightCells, badgeStrip, credentialFields, accountForm, returnNotice} from "@ofidj/entry/dom";
2
+ import {agreementScreen, bindAgreementScreen, bindPasswordReveal, acceptedAgreement, verificationWait, providerEntry, showEmailEntry, showVersionBadge, escape, masthead, highlightCells, badgeStrip, credentialFields, accountForm, returnNotice, passkeySupported, passkeyAssertion, walletDoor} from "@ofidj/entry/dom";
3
3
  import {openProviderWindow, relayProviderAnswer, type ProviderWindow} from "@ofidj/entry/window";
4
4
  import { FidjNodeService, FidjOidcClient } from "@ofidj/node";
5
5
  import config from "../app.config.json";
@@ -10,6 +10,11 @@ const oidc = config.oidcIssuer ? new FidjOidcClient({issuer: config.oidcIssuer,
10
10
  const root = document.querySelector<HTMLDivElement>("#app")!;
11
11
  showVersionBadge(config.releaseVersion, config.title === "Fidj" ? config.apiEndpoint : undefined);
12
12
  const appPath = `/me/apps/${encodeURIComponent(config.appId)}`;
13
+ // Passkeys (v3 P1-4): one relying party, Fidj's own domain. Only Fidj's own
14
+ // shell runs the ceremony on its page; every other app reaches the passkey
15
+ // through the Fidj window, like the rest of Fidj's sign-in.
16
+ const passkeyHere = config.title === "Fidj" && passkeySupported();
17
+ let signedInWithPasskey = false;
13
18
  let signedIn = false;
14
19
  let emailVerified = false;
15
20
  let anonymous = false;
@@ -45,7 +50,7 @@ let accountEmail = "";
45
50
  // with the agreement unchecked folded the form away and left the complaint
46
51
  // floating above a door the person could no longer see.
47
52
  let emailEntryOpen = false;
48
- const accountRoutes = ["forgot", "reset", "verify", "account"];
53
+ const accountRoutes = ["forgot", "reset", "verify", "profile"];
49
54
  let linkToken = "";
50
55
  let verificationConfirmed = false;
51
56
  function currentRoute() {
@@ -88,17 +93,23 @@ function banner() {
88
93
  // account, and it lives on that account's screen.
89
94
  function appNav(current: "content" | "account") {
90
95
  const tab = (id: string, label: string, selected: boolean) =>
91
- `<button id="${id}"${selected ? ' class="selected" aria-current="page"' : ""}>${label}</button>`;
96
+ `<button id="${id}"${selected ? ' class="selected" aria-current="page"' : ""}><span class="tab-label">${label}</span></button>`;
92
97
  const account = signedIn
93
98
  ? tab(
94
99
  "account-tab",
95
- accountEmail ? `Account (${escape(accountEmail)})` : "Account",
100
+ accountEmail ? `Profile (${escape(accountEmail)})` : "Profile",
96
101
  current === "account",
97
102
  )
98
103
  : tab("account-tab", "Sign in", false);
99
104
  return tab("content-tab", "Content", current === "content") + account;
100
105
  }
101
106
 
107
+ function profileSummary() {
108
+ const publicUrl = `${config.dashboardUrl}/#/pub/${encodeURIComponent(config.appId)}`;
109
+ const badgeUrl = `${config.apiEndpoint}/apps/${encodeURIComponent(config.appId)}/badge`;
110
+ return `<header class="profile-summary"><div class="profile-summary-copy"><span class="eyebrow">Profile</span><strong>${escape(accountEmail)}</strong><span>${escape(config.title)}</span></div><a class="profile-public" href="${escape(publicUrl)}" target="_blank" rel="noopener"><img src="${escape(badgeUrl)}" alt="${escape(config.title)} public badge" width="133" height="20"></a><button id="exit" class="danger">Sign out</button></header>`;
111
+ }
112
+
102
113
  // The bar belongs to the document, not to the screen being drawn: it survives
103
114
  // every route change, so it is filled rather than rebuilt with the page.
104
115
  function renderNav(current: "content" | "account") {
@@ -112,7 +123,7 @@ function renderNav(current: "content" | "account") {
112
123
  function wireNav() {
113
124
  element("content-tab")?.addEventListener("click", () => navigate("content"));
114
125
  element("account-tab")?.addEventListener("click", () =>
115
- navigate(signedIn ? "account" : "signin"),
126
+ navigate(signedIn ? "profile" : "signin"),
116
127
  );
117
128
  }
118
129
 
@@ -557,15 +568,15 @@ function render() {
557
568
  route = "signin";
558
569
  else if (!["signin", "content", "privacy", ...accountRoutes].includes(route))
559
570
  route = "content";
560
- // The privacy screen and the account screen became one. The address that
571
+ // The privacy screen and the profile screen became one. The address that
561
572
  // named the first still means something to anyone who bookmarked it, so it
562
573
  // arrives where that screen went rather than nowhere.
563
- if (route === "privacy") route = signedIn ? "account" : "signin";
574
+ if (route === "privacy") route = signedIn ? "profile" : "signin";
564
575
  window.history.replaceState(null, "", "#/" + route);
565
- // My account is a signed-in screen and keeps the app's chrome. Recovery and
576
+ // Profile is a signed-in screen and keeps the app's chrome. Recovery and
566
577
  // verification are reached without a session, so they stand alone.
567
578
  const standaloneAccount =
568
- accountRoutes.includes(route) && !(route === "account" && signedIn);
579
+ accountRoutes.includes(route) && !(route === "profile" && signedIn);
569
580
  document.body.classList.toggle(
570
581
  "signin-view",
571
582
  route === "signin" || standaloneAccount,
@@ -584,11 +595,11 @@ function render() {
584
595
  renderAccount(route);
585
596
  return;
586
597
  }
587
- if (route === "account") {
598
+ if (route === "profile") {
588
599
  // The two ways out, last and together: back to the app, or out of this
589
600
  // session. Leaving the app itself is a different decision and stays where
590
601
  // the things it erases are listed.
591
- root.innerHTML = `<section class="card content-account">${banner()}${accountForm("account", {linkToken, verificationConfirmed, emailVerified, accountEmail})}${privacyBlock()}<div class="account-actions"><button id="continue-app" class="primary">Continue to ${escape(config.title)}</button><button id="exit">Sign out</button></div></section>`;
602
+ root.innerHTML = `<section class="content-account">${profileSummary()}<div class="card profile-body">${banner()}${accountForm("account", {linkToken, verificationConfirmed, emailVerified, accountEmail}, {compact: true})}${privacyBlock()}</div></section>`;
592
603
  renderNav("account");
593
604
  wireAccount("account");
594
605
  wireSignOut();
@@ -614,8 +625,8 @@ function render() {
614
625
  root.innerHTML = `<section class="signin-shell"><div class="signin-intro${config.highlights?.length ? "" : " is-plain"}">${masthead(config.logo, config.title)}
615
626
  <div class="signin-identity"><h1>${escape(config.welcome)}</h1><p class="signin-description">${escape(config.description)}</p></div>
616
627
  ${highlightCells(config.highlights)}</div>
617
- <div class="signin-form"><div>${banner()}<h2>Sign in to ${escape(config.title)}</h2><form id="signin">${credentialFields({email: signInEmail, password: signInPassword})}</form>${config.allowAnonymous ? `<div class="signin-divider"><span>or explore first</span></div><button class="anonymous-entry" id="anonymous">Enter anonymously <span aria-hidden="true">→</span></button><p class="signin-footnote">No account needed to view the content.</p>` : ""}
618
- <div class="signin-trust"><p class="signin-trust-head"><img class="signin-logo" src="./fidj-logo.png" alt="Fidj"><strong>Your account, with Fidj</strong></p><p>Signing in creates one Fidj account you keep across every app that uses Fidj.</p><p>You choose what this app may store — and can export or erase it at any moment.</p></div></div>
628
+ <div class="signin-form"><div>${banner()}<h2>Sign in to ${escape(config.title)}</h2><form id="signin">${credentialFields({email: signInEmail, password: signInPassword}, {passkey: passkeyHere})}</form>${config.allowAnonymous ? `<div class="signin-divider"><span>or explore first</span></div><button class="anonymous-entry" id="anonymous">Enter anonymously <span aria-hidden="true">→</span></button><p class="signin-footnote">No account needed to view the content.</p>` : ""}
629
+ ${config.title === "Fidj" ? walletDoor() : `<div class="signin-trust"><p class="signin-trust-head"><img class="signin-logo" src="./fidj-logo.png" alt="Fidj"><strong>Your account, with Fidj</strong></p><p>Signing in creates one Fidj account you keep across every app that uses Fidj.</p><p>You choose what this app may store — and can export or erase it at any moment.</p></div>`}</div>
619
630
  ${badgeStrip(config.badges)}</div></section>`;
620
631
  wireNav();
621
632
  element("anonymous")?.addEventListener("click", () => {
@@ -636,7 +647,7 @@ function render() {
636
647
  config.appId,
637
648
  config.signin === "button"
638
649
  ? ""
639
- : credentialFields({email: signInEmail, password: signInPassword}),
650
+ : credentialFields({email: signInEmail, password: signInPassword}, {passkey: passkeyHere}),
640
651
  isFidjItself,
641
652
  config.signin as SigninShape,
642
653
  );
@@ -712,10 +723,21 @@ function render() {
712
723
  if (!acceptance) return;
713
724
  submitter?.setAttribute("data-busy", "true");
714
725
  void action(async () => {
715
- if (
716
- await refusedBeforeSignIn(signInEmail, signInPassword, false, acceptance)
717
- )
718
- return;
726
+ // A passkey sign-in has no password to send again: the agreement is
727
+ // accepted with a second touch of the same passkey.
728
+ const refused = signedInWithPasskey
729
+ ? await refusedBeforePasskey(acceptance)
730
+ : await refusedBeforeSignIn(signInEmail, signInPassword, false, acceptance);
731
+ if (refused) return;
732
+ await completeSignIn();
733
+ });
734
+ return;
735
+ }
736
+ // The first door (v3): the passkey, with nothing typed.
737
+ if (submitter?.name === "entry" && submitter.value === "passkey") {
738
+ submitter.setAttribute("data-busy", "true");
739
+ void action(async () => {
740
+ if (await refusedBeforePasskey()) return;
719
741
  await completeSignIn();
720
742
  });
721
743
  return;
@@ -758,6 +780,7 @@ async function refusedBeforeSignIn(
758
780
  signup: boolean,
759
781
  acceptance?: {termsAccepted: boolean; termsVersion: string},
760
782
  ): Promise<boolean> {
783
+ signedInWithPasskey = false;
761
784
  try {
762
785
  await sdk.login(email, password, {autoSignup: signup, ...acceptance});
763
786
  pendingAgreement = null;
@@ -782,6 +805,34 @@ async function refusedBeforeSignIn(
782
805
  }
783
806
  }
784
807
 
808
+ // The same answers as a password sign-in, from a passkey: the browser runs the
809
+ // ceremony against Fidj's challenge, and the SDK trades the answer for tokens.
810
+ async function refusedBeforePasskey(
811
+ acceptance?: {termsAccepted: boolean; termsVersion: string},
812
+ ): Promise<boolean> {
813
+ signedInWithPasskey = true;
814
+ try {
815
+ const {options, ticket} = await sdk.passkeyLoginOptions();
816
+ const response = await passkeyAssertion(options);
817
+ await sdk.loginWithPasskey(ticket, response, acceptance);
818
+ pendingAgreement = null;
819
+ awaitingVerification = "";
820
+ return false;
821
+ } catch (error) {
822
+ if (agreementRequired(error)) {
823
+ pendingAgreement = agreementFromRefusal(error) || (await readAgreement());
824
+ if (!pendingAgreement) {
825
+ throw new Error("We cannot reach Fidj right now. Please try again.");
826
+ }
827
+ return true;
828
+ }
829
+ if ((error as any)?.name === "NotAllowedError") {
830
+ throw new Error("No passkey was used. Try again, or sign in with your email.");
831
+ }
832
+ throw new Error(signInErrorMessage(error));
833
+ }
834
+ }
835
+
785
836
  // An API that refused without saying which agreement it wanted. Older ones
786
837
  // cannot say, so the app is asked directly; it is the same agreement unless the
787
838
  // owner published between the two calls.
@@ -213,7 +213,7 @@ function render() {
213
213
  const canWrite = session?.roles.some((role) =>
214
214
  ["Owner", "Editor"].includes(role),
215
215
  );
216
- root.innerHTML = `<header class="topbar"><a class="brand" href="/"><span class="app-symbol">✳</span>${escape(settings.title)}</a><a class="fidj-brand" href="${escape(settings.dashboardUrl)}" target="_blank" rel="noopener"><img src="/fidj-logo.png" alt="Fidj">Privacy with Fidj ↗</a></header>
216
+ root.innerHTML = `<header class="topbar"><a class="brand" href="/"><span class="app-symbol">✳</span><span class="brand-name">${escape(settings.title)}</span></a><a class="fidj-brand" href="${escape(settings.dashboardUrl)}" target="_blank" rel="noopener"><img src="/fidj-logo.png" alt="Fidj">Privacy with Fidj ↗</a></header>
217
217
  <main>${notice ? `<p class="notice" role="status">${escape(notice)}</p>` : ""}${error ? `<p class="error" role="alert">${escape(error)}</p>` : ""}
218
218
  ${
219
219
  !session
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ofidj/generator-fidj",
3
- "version": "3.13.0",
3
+ "version": "3.15.0",
4
4
  "description": "Generate a TypeScript app with Fidj sign-in, live server-side roles and per-app privacy.",
5
5
  "homepage": "https://fidj.ovh",
6
6
  "bugs": {