@ofidj/generator-fidj 1.0.0 → 1.0.3

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.
Files changed (161) hide show
  1. package/README.md +136 -57
  2. package/bin/create-fidj.cjs +62 -0
  3. package/generators/app/index.js +100 -189
  4. package/generators/app/templates/typescript/README.md +82 -0
  5. package/generators/app/templates/typescript/app.config.json +14 -0
  6. package/generators/app/templates/typescript/gitignore +5 -0
  7. package/generators/app/templates/typescript/package.json +25 -0
  8. package/generators/app/templates/typescript/public/fidj-logo.png +0 -0
  9. package/generators/app/templates/typescript/public/fonts/IBMPlexMono-400-latin-ext.woff2 +0 -0
  10. package/generators/app/templates/typescript/public/fonts/IBMPlexMono-400-latin.woff2 +0 -0
  11. package/generators/app/templates/typescript/public/fonts/IBMPlexMono-500-latin-ext.woff2 +0 -0
  12. package/generators/app/templates/typescript/public/fonts/IBMPlexMono-500-latin.woff2 +0 -0
  13. package/generators/app/templates/typescript/public/fonts/IBMPlexSans-400_600-latin-ext.woff2 +0 -0
  14. package/generators/app/templates/typescript/public/fonts/IBMPlexSans-400_600-latin.woff2 +0 -0
  15. package/generators/app/templates/typescript/public/fonts/InstrumentSerif-400-latin-ext.woff2 +0 -0
  16. package/generators/app/templates/typescript/public/fonts/InstrumentSerif-400-latin.woff2 +0 -0
  17. package/generators/app/templates/typescript/public/index.html +16 -0
  18. package/generators/app/templates/typescript/scripts/build.mjs +49 -0
  19. package/generators/app/templates/typescript/scripts/privacy-check.mjs +21 -0
  20. package/generators/app/templates/typescript/scripts/render-content.mjs +14 -0
  21. package/generators/app/templates/typescript/server/data-store.ts +123 -0
  22. package/generators/app/templates/typescript/server/index.ts +338 -0
  23. package/generators/app/templates/typescript/server/privacy-adapter.ts +21 -0
  24. package/generators/app/templates/typescript/src/content.ts +491 -0
  25. package/generators/app/templates/typescript/src/fonts.css +92 -0
  26. package/generators/app/templates/typescript/src/main.ts +251 -0
  27. package/generators/app/templates/typescript/src/service-agreement.ts +40 -0
  28. package/generators/app/templates/typescript/src/style.css +916 -0
  29. package/generators/app/templates/typescript/src/tokens.css +56 -0
  30. package/generators/app/templates/typescript/test/server.test.mjs +287 -0
  31. package/generators/app/templates/typescript/tsconfig.json +15 -0
  32. package/lib/app-module.cjs +64 -0
  33. package/lib/scaffold.cjs +214 -0
  34. package/package.json +15 -13
  35. package/generators/app/templates/app2018/.travis.yml +0 -35
  36. package/generators/app/templates/app2018/LICENSE +0 -21
  37. package/generators/app/templates/app2018/Procfile +0 -1
  38. package/generators/app/templates/app2018/README.md +0 -49
  39. package/generators/app/templates/app2018/config.xml +0 -91
  40. package/generators/app/templates/app2018/gulpfile.js +0 -63
  41. package/generators/app/templates/app2018/ionic.config.json +0 -9
  42. package/generators/app/templates/app2018/package.json +0 -90
  43. package/generators/app/templates/app2018/platforms/README.md +0 -1
  44. package/generators/app/templates/app2018/resources/README.md +0 -6
  45. package/generators/app/templates/app2018/resources/icon.png +0 -0
  46. package/generators/app/templates/app2018/resources/marketing-01-ipad.png +0 -0
  47. package/generators/app/templates/app2018/resources/marketing-01-iphone.png +0 -0
  48. package/generators/app/templates/app2018/resources/marketing-02-ipad.png +0 -0
  49. package/generators/app/templates/app2018/resources/marketing-02-iphone.png +0 -0
  50. package/generators/app/templates/app2018/resources/marketing-03-ipad.png +0 -0
  51. package/generators/app/templates/app2018/resources/marketing-03-iphone.png +0 -0
  52. package/generators/app/templates/app2018/resources/marketing.json +0 -19
  53. package/generators/app/templates/app2018/resources/splash.png +0 -0
  54. package/generators/app/templates/app2018/src/app/app.component.ts +0 -88
  55. package/generators/app/templates/app2018/src/app/app.html +0 -19
  56. package/generators/app/templates/app2018/src/app/app.module.ts +0 -40
  57. package/generators/app/templates/app2018/src/app/app.scss +0 -16
  58. package/generators/app/templates/app2018/src/app/main.ts +0 -5
  59. package/generators/app/templates/app2018/src/app/shared/card.model.ts +0 -10
  60. package/generators/app/templates/app2018/src/app/shared/profile.service.ts +0 -46
  61. package/generators/app/templates/app2018/src/app/shared/shared.module.ts +0 -35
  62. package/generators/app/templates/app2018/src/app/shared/storage.service.ts +0 -11
  63. package/generators/app/templates/app2018/src/app/shared/version.const.ts +0 -5
  64. package/generators/app/templates/app2018/src/assets/icon/favicon.ico +0 -0
  65. package/generators/app/templates/app2018/src/assets/imgs/gg.png +0 -0
  66. package/generators/app/templates/app2018/src/assets/imgs/github.png +0 -0
  67. package/generators/app/templates/app2018/src/assets/imgs/login.svg +0 -5668
  68. package/generators/app/templates/app2018/src/assets/imgs/logo.png +0 -0
  69. package/generators/app/templates/app2018/src/index.html +0 -49
  70. package/generators/app/templates/app2018/src/manifest.json +0 -13
  71. package/generators/app/templates/app2018/src/pages/home/home.html +0 -56
  72. package/generators/app/templates/app2018/src/pages/home/home.scss +0 -3
  73. package/generators/app/templates/app2018/src/pages/home/home.ts +0 -126
  74. package/generators/app/templates/app2018/src/pages/login/login.html +0 -56
  75. package/generators/app/templates/app2018/src/pages/login/login.scss +0 -44
  76. package/generators/app/templates/app2018/src/pages/login/login.ts +0 -59
  77. package/generators/app/templates/app2018/src/pages/profile/profile.html +0 -33
  78. package/generators/app/templates/app2018/src/pages/profile/profile.scss +0 -3
  79. package/generators/app/templates/app2018/src/pages/profile/profile.ts +0 -36
  80. package/generators/app/templates/app2018/src/service-worker.js +0 -31
  81. package/generators/app/templates/app2018/src/theme/variables.scss +0 -88
  82. package/generators/app/templates/app2018/tests/karma.unit-tests.conf.js +0 -84
  83. package/generators/app/templates/app2018/tests/unit/app.test.js +0 -10
  84. package/generators/app/templates/app2018/tsconfig.json +0 -28
  85. package/generators/app/templates/app2018/tslint.json +0 -8
  86. package/generators/app/templates/app2018/web.js +0 -21
  87. package/generators/app/templates/app2021/.travis.yml +0 -32
  88. package/generators/app/templates/app2021/LICENSE +0 -21
  89. package/generators/app/templates/app2021/Procfile +0 -1
  90. package/generators/app/templates/app2021/README.md +0 -45
  91. package/generators/app/templates/app2021/angular.json +0 -188
  92. package/generators/app/templates/app2021/browserslist +0 -12
  93. package/generators/app/templates/app2021/ionic.config.json +0 -9
  94. package/generators/app/templates/app2021/karma.conf.js +0 -31
  95. package/generators/app/templates/app2021/package-lock.json +0 -5
  96. package/generators/app/templates/app2021/package.json +0 -77
  97. package/generators/app/templates/app2021/platforms/README.md +0 -1
  98. package/generators/app/templates/app2021/resources/README.md +0 -6
  99. package/generators/app/templates/app2021/resources/icon.png +0 -0
  100. package/generators/app/templates/app2021/resources/marketing-01-ipad.png +0 -0
  101. package/generators/app/templates/app2021/resources/marketing-01-iphone.png +0 -0
  102. package/generators/app/templates/app2021/resources/marketing-02-ipad.png +0 -0
  103. package/generators/app/templates/app2021/resources/marketing-02-iphone.png +0 -0
  104. package/generators/app/templates/app2021/resources/marketing-03-ipad.png +0 -0
  105. package/generators/app/templates/app2021/resources/marketing-03-iphone.png +0 -0
  106. package/generators/app/templates/app2021/resources/marketing.json +0 -19
  107. package/generators/app/templates/app2021/resources/splash.png +0 -0
  108. package/generators/app/templates/app2021/src/app/app-routing.module.ts +0 -25
  109. package/generators/app/templates/app2021/src/app/app.component.html +0 -3
  110. package/generators/app/templates/app2021/src/app/app.component.scss +0 -0
  111. package/generators/app/templates/app2021/src/app/app.component.spec.ts +0 -50
  112. package/generators/app/templates/app2021/src/app/app.component.ts +0 -28
  113. package/generators/app/templates/app2021/src/app/app.module.ts +0 -24
  114. package/generators/app/templates/app2021/src/app/content/content-routing.module.ts +0 -16
  115. package/generators/app/templates/app2021/src/app/content/content.module.ts +0 -20
  116. package/generators/app/templates/app2021/src/app/content/content.page.html +0 -31
  117. package/generators/app/templates/app2021/src/app/content/content.page.scss +0 -0
  118. package/generators/app/templates/app2021/src/app/content/content.page.ts +0 -26
  119. package/generators/app/templates/app2021/src/app/explore-container/explore-container.component.html +0 -4
  120. package/generators/app/templates/app2021/src/app/explore-container/explore-container.component.scss +0 -4
  121. package/generators/app/templates/app2021/src/app/explore-container/explore-container.component.spec.ts +0 -24
  122. package/generators/app/templates/app2021/src/app/explore-container/explore-container.component.ts +0 -16
  123. package/generators/app/templates/app2021/src/app/explore-container/explore-container.module.ts +0 -14
  124. package/generators/app/templates/app2021/src/app/my/my-routing.module.ts +0 -36
  125. package/generators/app/templates/app2021/src/app/my/my.module.ts +0 -19
  126. package/generators/app/templates/app2021/src/app/my/my.page.html +0 -15
  127. package/generators/app/templates/app2021/src/app/my/my.page.scss +0 -1
  128. package/generators/app/templates/app2021/src/app/my/my.page.spec.ts +0 -26
  129. package/generators/app/templates/app2021/src/app/my/my.page.ts +0 -25
  130. package/generators/app/templates/app2021/src/app/profile/profile-routing.module.ts +0 -16
  131. package/generators/app/templates/app2021/src/app/profile/profile.module.ts +0 -20
  132. package/generators/app/templates/app2021/src/app/profile/profile.page.html +0 -49
  133. package/generators/app/templates/app2021/src/app/profile/profile.page.scss +0 -0
  134. package/generators/app/templates/app2021/src/app/profile/profile.page.spec.ts +0 -25
  135. package/generators/app/templates/app2021/src/app/profile/profile.page.ts +0 -44
  136. package/generators/app/templates/app2021/src/app/shared/const.ts +0 -3
  137. package/generators/app/templates/app2021/src/app/signin/signin-routing.module.ts +0 -16
  138. package/generators/app/templates/app2021/src/app/signin/signin.module.ts +0 -18
  139. package/generators/app/templates/app2021/src/app/signin/signin.page.html +0 -53
  140. package/generators/app/templates/app2021/src/app/signin/signin.page.scss +0 -36
  141. package/generators/app/templates/app2021/src/app/signin/signin.page.ts +0 -69
  142. package/generators/app/templates/app2021/src/assets/icon/favicon.png +0 -0
  143. package/generators/app/templates/app2021/src/assets/shapes.svg +0 -1
  144. package/generators/app/templates/app2021/src/environments/environment.prod.ts +0 -3
  145. package/generators/app/templates/app2021/src/environments/environment.ts +0 -16
  146. package/generators/app/templates/app2021/src/global.scss +0 -26
  147. package/generators/app/templates/app2021/src/index.html +0 -26
  148. package/generators/app/templates/app2021/src/main.ts +0 -12
  149. package/generators/app/templates/app2021/src/polyfills.ts +0 -67
  150. package/generators/app/templates/app2021/src/test.ts +0 -20
  151. package/generators/app/templates/app2021/src/theme/variables.scss +0 -232
  152. package/generators/app/templates/app2021/src/zone-flags.ts +0 -5
  153. package/generators/app/templates/app2021/tests/protractor.conf.js +0 -36
  154. package/generators/app/templates/app2021/tests/src/app.e2e-spec.ts +0 -14
  155. package/generators/app/templates/app2021/tests/src/app.po.ts +0 -11
  156. package/generators/app/templates/app2021/tests/tsconfig.json +0 -13
  157. package/generators/app/templates/app2021/tsconfig.app.json +0 -18
  158. package/generators/app/templates/app2021/tsconfig.json +0 -23
  159. package/generators/app/templates/app2021/tsconfig.spec.json +0 -18
  160. package/generators/app/templates/app2021/tslint.json +0 -149
  161. package/generators/app/templates/app2021/web.js +0 -21
@@ -0,0 +1,56 @@
1
+ /* The Fidj design system, v2 — every value the product paints with.
2
+ *
3
+ * This file is shared verbatim with fidj-app (src/theme/fidj-tokens.css), so a
4
+ * style change made here reaches Fidj, mleweb and every generated app at once.
5
+ * Keep it free of anything path-dependent: @font-face lives in fonts.css, and
6
+ * each consumer supplies its own. Nothing else in a stylesheet may introduce a
7
+ * literal colour, family or radius.
8
+ */
9
+
10
+ :root {
11
+ /* Ink — the dark half of the split: brand panel, header, primary button. */
12
+ --fidj-ink: #14110f;
13
+ --fidj-ink-raised: #2a2521;
14
+ --fidj-ink-line: #38322c;
15
+ --fidj-ink-line-soft: #4a423b;
16
+ --fidj-on-ink: #f2eee9;
17
+ --fidj-on-ink-muted: #b6ada5;
18
+ --fidj-on-ink-faint: #8c847c;
19
+
20
+ /* Paper — the light half. */
21
+ --fidj-paper: #fbfaf8;
22
+ --fidj-surface: #ffffff;
23
+ --fidj-surface-sunk: #f5f1ec;
24
+ --fidj-surface-tint: #f2eee9;
25
+ --fidj-line: #e4ded7;
26
+ --fidj-line-soft: #ece7e1;
27
+ --fidj-line-strong: #dfd9d2;
28
+ --fidj-line-hover: #c9c1b8;
29
+ --fidj-text: #14110f;
30
+ --fidj-text-muted: #4a4540;
31
+ --fidj-text-faint: #6b655f;
32
+ --fidj-placeholder: #a8a099;
33
+
34
+ /* Accents. The red is the Fidj mark; it identifies the account and privacy
35
+ * provider, so it stays sparing — a dot, a rule, a hover. */
36
+ --fidj-accent: #d6342c;
37
+ --fidj-danger: #b8352c;
38
+ --fidj-danger-strong: #8c2a22;
39
+ --fidj-danger-tint: #fdf7f6;
40
+ --fidj-danger-line: #e8d9d6;
41
+ --fidj-ok: #3f7a4f;
42
+ --fidj-warn: #c07a1e;
43
+ --fidj-warn-tint: #fbf7f1;
44
+
45
+ --fidj-focus-ring: 0 0 0 3px rgb(20 17 15 / 0.07);
46
+ --fidj-overlay-shadow: 0 12px 32px rgb(20 17 15 / 0.18);
47
+
48
+ --fidj-font-sans: "IBM Plex Sans", system-ui, -apple-system, sans-serif;
49
+ --fidj-font-mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, monospace;
50
+ --fidj-font-display: "Instrument Serif", Georgia, "Times New Roman", serif;
51
+
52
+ /* Squared corners are the loudest signal of this style: 2px everywhere, and
53
+ * a full round only for avatars and status dots. */
54
+ --fidj-radius: 2px;
55
+ --fidj-shell-max: 1180px;
56
+ }
@@ -0,0 +1,287 @@
1
+ import {execFile} from "node:child_process";
2
+ import {promisify} from "node:util";
3
+ import { test } from "node:test";
4
+ import assert from "node:assert/strict";
5
+ import { createServer } from "node:http";
6
+ import { mkdtemp, rm, writeFile, readdir } from "node:fs/promises";
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+ import { createHmac } from "node:crypto";
10
+ import { createRequire } from "node:module";
11
+ const { createApp, DataStore } = createRequire(import.meta.url)(
12
+ "../dist/server.cjs",
13
+ );
14
+ const listen = (server) =>
15
+ new Promise((resolve) =>
16
+ server.listen(0, "127.0.0.1", () =>
17
+ resolve(`http://127.0.0.1:${server.address().port}`),
18
+ ),
19
+ );
20
+ const close = (server) => new Promise((resolve) => server.close(resolve));
21
+ const token = (subject, appId = "test-app") =>
22
+ `header.${Buffer.from(JSON.stringify({ sub: subject, name: subject + "@test.local", aud: appId, roles: ["Owner"] })).toString("base64url")}.signature`;
23
+
24
+ test("enforces live app roles, identity isolation and scoped privacy over HTTP", async () => {
25
+ const directory = await mkdtemp(join(tmpdir(), "fidj-data-test-"));
26
+ const adapterKey = "test-only-privacy-adapter-secret-32-chars";
27
+ let appUrl;
28
+ const adapterCall = async (
29
+ operation,
30
+ subject,
31
+ requestId = "departure-maya",
32
+ overrides = {},
33
+ ) => {
34
+ const data = { operation, subject, appId: "test-app", requestId };
35
+ const timestamp = String(Date.now());
36
+ const signature = createHmac("sha256", adapterKey)
37
+ .update(timestamp + "." + JSON.stringify(data))
38
+ .digest("hex");
39
+ return fetch(appUrl + "/fidj/privacy", {
40
+ method: "POST",
41
+ headers: {
42
+ "Content-Type": "application/json",
43
+ "x-fidj-timestamp": timestamp,
44
+ "x-fidj-signature": signature,
45
+ ...overrides,
46
+ },
47
+ body: JSON.stringify(data),
48
+ });
49
+ };
50
+ const maya = token("maya");
51
+ const sam = token("sam");
52
+ const assignments = new Map([
53
+ [maya, ["Free"]],
54
+ [sam, ["Editor"]],
55
+ ]);
56
+ const consents = new Map();
57
+ let outage = false;
58
+ const issuer = createServer(async (req, res) => {
59
+ const bearer = req.headers.authorization?.slice(7);
60
+ const roles = assignments.get(bearer);
61
+ res.setHeader("Content-Type", "application/json");
62
+ if (outage) {
63
+ res.writeHead(503);
64
+ return res.end("{}");
65
+ }
66
+ if (!roles) {
67
+ res.writeHead(403);
68
+ return res.end("{}");
69
+ }
70
+ if (req.url === "/apps/test-app/me")
71
+ return res.end(
72
+ JSON.stringify({ roles: roles.map((type) => ({ type })) }),
73
+ );
74
+ if (req.url === "/me/apps/test-app/consents/history")
75
+ return res.end('{"history":[]}');
76
+ if (req.url === "/me/apps/test-app/consents") {
77
+ if (req.method === "PUT") {
78
+ let text = "";
79
+ for await (const chunk of req) text += chunk;
80
+ consents.set(bearer, JSON.parse(text));
81
+ }
82
+ return res.end(
83
+ JSON.stringify({
84
+ ...(consents.get(bearer) || { terms: true }),
85
+ appDataConnected: true,
86
+ }),
87
+ );
88
+ }
89
+ if (req.url === "/me/apps/test-app/export")
90
+ return res.end(JSON.stringify({ consent: consents.get(bearer) }));
91
+ if (req.url === "/me/apps/test-app" && req.method === "DELETE") {
92
+ const erased = await adapterCall(
93
+ "erase",
94
+ JSON.parse(Buffer.from(bearer.split(".")[1], "base64url")).sub,
95
+ );
96
+ if (!erased.ok) {
97
+ res.writeHead(202);
98
+ return res.end('{"status":"pending"}');
99
+ }
100
+ assignments.delete(bearer);
101
+ return res.end('{"status":"completed"}');
102
+ }
103
+ res.writeHead(404);
104
+ res.end("{}");
105
+ });
106
+ const apiEndpoint = await listen(issuer);
107
+ const settings = {
108
+ appId: "test-app",
109
+ apiEndpoint,
110
+ dashboardUrl: "https://fidj.ovh",
111
+ title: "Test app",
112
+ localDemo: false,
113
+ };
114
+ const persistence = new DataStore(directory, "test-app");
115
+ const erase = persistence.erase.bind(persistence);
116
+ let storageUnavailable = false;
117
+ persistence.erase = async (...args) => {
118
+ if (storageUnavailable) throw new Error("Simulated storage outage");
119
+ return erase(...args);
120
+ };
121
+ let app = createApp(settings, {
122
+ dataDir: directory,
123
+ adapterKey,
124
+ store: persistence,
125
+ });
126
+ let url = await listen(app);
127
+ appUrl = url;
128
+ const call = (path, bearer = maya, method = "GET", data) =>
129
+ fetch(url + "/api/" + path, {
130
+ method,
131
+ headers: {
132
+ Authorization: "Bearer " + bearer,
133
+ "Content-Type": "application/json",
134
+ },
135
+ body: data === undefined ? undefined : JSON.stringify(data),
136
+ });
137
+ try {
138
+ assert.equal((await fetch(url + "/api/notes")).status, 401);
139
+ assert.equal(
140
+ (await call("session", token("maya", "other-app"))).status,
141
+ 401,
142
+ );
143
+ assert.equal((await call("session", maya + "tampered")).status, 401);
144
+ assert.equal(
145
+ (await call("notes", maya, "POST", { title: "Hello", body: "Private" }))
146
+ .status,
147
+ 403,
148
+ );
149
+ assignments.set(maya, ["Editor"]);
150
+ assert.equal(
151
+ (await call("notes", maya, "POST", { title: "Hello", body: "Private" }))
152
+ .status,
153
+ 201,
154
+ );
155
+ assert.equal((await (await call("notes", sam)).json()).notes.length, 0);
156
+ assert.equal((await (await call("notes")).json()).notes.length, 1);
157
+ await call("notes", sam, "POST", { title: "Sam only", body: "Preserve" });
158
+ await close(app);
159
+ app = createApp(settings, {
160
+ dataDir: directory,
161
+ adapterKey,
162
+ store: persistence,
163
+ });
164
+ url = await listen(app);
165
+ appUrl = url;
166
+ assert.equal((await (await call("notes")).json()).notes[0].title, "Hello");
167
+ assert.equal(
168
+ (
169
+ await adapterCall("export", "maya", "export-test", {
170
+ "x-fidj-signature": "0".repeat(64),
171
+ })
172
+ ).status,
173
+ 401,
174
+ );
175
+ assert.equal(
176
+ (
177
+ await adapterCall("export", "maya", "export-test", {
178
+ "x-fidj-timestamp": "1",
179
+ })
180
+ ).status,
181
+ 401,
182
+ );
183
+ assert.equal(
184
+ (await (await adapterCall("export", "maya", "export-test")).json()).data
185
+ .notes.length,
186
+ 1,
187
+ );
188
+ assert.equal(
189
+ (await (await adapterCall("export", "sam", "export-sam")).json()).data
190
+ .notes.length,
191
+ 1,
192
+ );
193
+ assert.equal((await fetch(url + "/.fidj-data/data.json")).status, 404);
194
+ const readiness = await (await adapterCall("check", "readiness-check", "check-test")).json();
195
+ assert.equal(readiness.storage, "ready");
196
+ assert.deepEqual(readiness.capabilities, ["export", "erase"]);
197
+ const checked = await promisify(execFile)(process.execPath, ["scripts/privacy-check.mjs"], {env: {...process.env, FIDJ_APP_ID: "test-app", FIDJ_APP_URL: url, FIDJ_PRIVACY_ADAPTER_KEY: adapterKey}});
198
+ assert.match(checked.stdout, /Ready:/);
199
+ await assert.rejects(promisify(execFile)(process.execPath, ["scripts/privacy-check.mjs"], {env: {...process.env, FIDJ_APP_ID: "test-app", FIDJ_APP_URL: url, FIDJ_PRIVACY_ADAPTER_KEY: "wrong-key-that-is-long-enough-for-check"}}));
200
+ assert.equal(
201
+ (await (await fetch(url + "/api/config")).json()).adapterKey,
202
+ undefined,
203
+ );
204
+ assignments.set(maya, ["Free"]);
205
+ assert.equal(
206
+ (await call("notes", maya, "POST", { title: "Denied", body: "" })).status,
207
+ 403,
208
+ );
209
+ assert.equal(
210
+ (await call("privacy", maya, "PUT", { analytics: true })).status,
211
+ 200,
212
+ );
213
+ assert.equal(consents.get(maya).analytics, true);
214
+ assert.equal(consents.has(sam), false);
215
+ assert.equal(
216
+ (await (await call("privacy/export")).json()).app.notes.length,
217
+ 1,
218
+ );
219
+ outage = true;
220
+ assert.equal((await call("notes")).status, 503);
221
+ outage = false;
222
+ assert.equal(
223
+ (await call("privacy/leave", maya, "DELETE", { confirm: "other-app" }))
224
+ .status,
225
+ 400,
226
+ );
227
+ storageUnavailable = true;
228
+ assert.equal((await adapterCall("erase", "maya")).status, 500);
229
+ assert.equal((await (await call("notes")).json()).notes.length, 1);
230
+ storageUnavailable = false;
231
+ await writeFile(
232
+ join(directory, "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa.tmp"),
233
+ "Interrupted private data copy",
234
+ );
235
+ assert.equal(
236
+ (await call("privacy/leave", maya, "DELETE", { confirm: "test-app" }))
237
+ .status,
238
+ 200,
239
+ );
240
+ assert.equal((await call("notes")).status, 401);
241
+ assert.equal(
242
+ (await readdir(directory)).filter((name) => name.endsWith(".tmp")).length,
243
+ 0,
244
+ );
245
+ assignments.set(maya, ["Free"]);
246
+ assert.equal((await (await call("notes")).json()).notes.length, 0);
247
+ assert.equal((await call("session", sam)).status, 200);
248
+ assert.equal(
249
+ (await (await call("notes", sam)).json()).notes[0].title,
250
+ "Sam only",
251
+ );
252
+ assignments.set(maya, ["Editor"]);
253
+ await call("notes", maya, "POST", {
254
+ title: "New membership",
255
+ body: "Keep this",
256
+ });
257
+ await close(app);
258
+ app = createApp(settings, {
259
+ dataDir: directory,
260
+ adapterKey,
261
+ store: persistence,
262
+ });
263
+ url = await listen(app);
264
+ appUrl = url;
265
+ assert.equal((await adapterCall("erase", "maya")).status, 200);
266
+ assert.equal(
267
+ (await (await call("notes")).json()).notes[0].title,
268
+ "New membership",
269
+ );
270
+ assert.equal((await adapterCall("erase", "sam")).status, 500);
271
+ const store = new DataStore(directory, "test-app");
272
+ await assert.rejects(
273
+ store.add(
274
+ "maya",
275
+ { id: "denied", title: "Race", body: "", createdAt: "" },
276
+ async () => {
277
+ throw new Error("Revoked during operation");
278
+ },
279
+ ),
280
+ );
281
+ assert.equal((await store.notes("maya")).length, 1);
282
+ } finally {
283
+ await close(app);
284
+ await close(issuer);
285
+ await rm(directory, { recursive: true, force: true });
286
+ }
287
+ });
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "skipLibCheck": true,
9
+ "lib": ["ES2022", "DOM"],
10
+ "types": ["node"],
11
+ "noEmit": true,
12
+ "resolveJsonModule": true
13
+ },
14
+ "include": ["src/**/*.ts", "server/**/*.ts"]
15
+ }
@@ -0,0 +1,64 @@
1
+ const fs = require("node:fs");
2
+ const path = require("node:path");
3
+
4
+ function inspectModule(source, entry = "index.html", destination) {
5
+ const root = fs.realpathSync(path.resolve(source));
6
+ const output = path.resolve(destination);
7
+ if (!fs.statSync(root).isDirectory())
8
+ throw new Error("Module must be a built website directory.");
9
+ if (
10
+ output === root ||
11
+ output.startsWith(root + path.sep) ||
12
+ root.startsWith(output + path.sep)
13
+ )
14
+ throw new Error(
15
+ "Module source and generated destination must not contain each other.",
16
+ );
17
+ if (/^(?:[a-z]+:|\/\/)/i.test(entry))
18
+ throw new Error("Module entry must be relative.");
19
+ const url = new URL(entry, "https://module.invalid/module/");
20
+ if (
21
+ url.origin !== "https://module.invalid" ||
22
+ !url.pathname.startsWith("/module/") ||
23
+ url.search ||
24
+ entry.startsWith("/")
25
+ )
26
+ throw new Error(
27
+ "Module entry must be a relative HTML path inside the module.",
28
+ );
29
+ const file = decodeURIComponent(url.pathname.slice("/module/".length));
30
+ if (
31
+ !file.endsWith(".html") ||
32
+ file.split("/").some((part) => part === "..") ||
33
+ file.includes("\\")
34
+ )
35
+ throw new Error(
36
+ "Module entry must be a relative HTML path inside the module.",
37
+ );
38
+ if (!fs.statSync(path.join(root, file)).isFile())
39
+ throw new Error("Module entry does not exist.");
40
+ if (!fs.readFileSync(path.join(root, file), "utf8").includes("</head>"))
41
+ throw new Error("Module entry must have an HTML head.");
42
+ const visit = (directory) => {
43
+ for (const item of fs.readdirSync(directory, { withFileTypes: true })) {
44
+ if (item.isSymbolicLink())
45
+ throw new Error("Built modules must not contain symbolic links.");
46
+ if (
47
+ [".git", "node_modules", ".env"].includes(item.name) ||
48
+ item.name.startsWith(".env.")
49
+ )
50
+ throw new Error(
51
+ "Supply only the module's public build output, without environment or dependency files.",
52
+ );
53
+ if (item.isDirectory()) visit(path.join(directory, item.name));
54
+ }
55
+ };
56
+ visit(root);
57
+ // Address a directory rather than its index file: the generated server and
58
+ // any static host resolve it, and no index.html shows up in the address bar.
59
+ const address = file.endsWith("index.html")
60
+ ? file.slice(0, -"index.html".length)
61
+ : file;
62
+ return { root, file, entry: "./module/" + address + url.hash };
63
+ }
64
+ module.exports = { inspectModule };
@@ -0,0 +1,214 @@
1
+ const fs = require("node:fs");
2
+ const path = require("node:path");
3
+ const { inspectModule } = require("./app-module.cjs");
4
+ const templateRoot = path.join(
5
+ __dirname,
6
+ "../generators/app/templates/typescript",
7
+ );
8
+
9
+ function scaffold(destination, options) {
10
+ const anonymous = options.anonymous ?? true;
11
+ if (![true, false, "true", "false"].includes(anonymous))
12
+ throw new Error("--anonymous must be true or false.");
13
+ const name = options.name || path.basename(path.resolve(destination));
14
+ if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(name))
15
+ throw new Error(
16
+ "Use a lowercase project name with letters, numbers and hyphens.",
17
+ );
18
+ if (!/^[a-zA-Z0-9_-]+$/.test(options.appId || ""))
19
+ throw new Error("Supply your app’s public fidjId with --app-id.");
20
+ const api = new URL(options.apiEndpoint || "https://api.sandbox.fidj.ovh/v3");
21
+ if (
22
+ api.username ||
23
+ api.password ||
24
+ api.search ||
25
+ api.hash ||
26
+ !["http:", "https:"].includes(api.protocol)
27
+ )
28
+ throw new Error(
29
+ "Use an API URL without credentials, a query or a fragment.",
30
+ );
31
+ if (
32
+ api.protocol === "http:" &&
33
+ !["localhost", "127.0.0.1", "[::1]"].includes(api.hostname)
34
+ )
35
+ throw new Error("Use HTTPS for a remote API.");
36
+ if (options.oidcIssuer) {
37
+ const issuer = new URL(options.oidcIssuer);
38
+ if (issuer.origin !== api.origin || issuer.pathname !== "/oidc" || issuer.hash || issuer.search || issuer.username || issuer.password) throw new Error("OIDC issuer must share the API origin and use /oidc.");
39
+ if (options.content === undefined && !options.module) throw new Error("OIDC currently requires a content app or console module.");
40
+ }
41
+ const title = options.title || name;
42
+ if (/[\r\n]/.test(title)) throw new Error("Title must be one line.");
43
+ // Sign-in highlights are the app's own selling points, so they are supplied
44
+ // per app rather than baked in: an app that passes none simply shows none.
45
+ const highlights = (options.highlights || []).map((entry) => {
46
+ const [heading, ...rest] = String(entry).split("|");
47
+ const body = rest.join("|").trim();
48
+ if (!heading.trim() || !body)
49
+ throw new Error('Each --highlight must read "<heading>|<body>".');
50
+ if (/[\r\n]/.test(entry)) throw new Error("A highlight must be one line.");
51
+ return { heading: heading.trim(), body };
52
+ });
53
+ if (highlights.length > 6)
54
+ throw new Error("Six highlights is the most the sign-in panel can hold.");
55
+ // Trust badges are claims about a specific app — where it is hosted, what it
56
+ // audits. None are supplied by default, because an app cannot inherit them.
57
+ const badges = (options.badges || []).map((entry) => {
58
+ const text = String(entry).trim();
59
+ if (!text || /[\r\n]/.test(text))
60
+ throw new Error("Each --badge must be one line of text.");
61
+ if (text.length > 40)
62
+ throw new Error("A badge must be at most 40 characters.");
63
+ return text;
64
+ });
65
+ if (badges.length > 4)
66
+ throw new Error("Four badges is the most the sign-in footer can hold.");
67
+ if (
68
+ options.domain &&
69
+ !/^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/i.test(options.domain)
70
+ )
71
+ throw new Error("Domain must be a hostname, without a scheme or path.");
72
+ const brandImage = (value, flag) => {
73
+ if (!value) return null;
74
+ const source = path.resolve(value);
75
+ if (!fs.existsSync(source) || !fs.statSync(source).isFile())
76
+ throw new Error(`${flag} must point at an image file.`);
77
+ const extension = path.extname(source).toLowerCase();
78
+ if (
79
+ ![".png", ".svg", ".gif", ".jpg", ".jpeg", ".webp", ".ico"].includes(
80
+ extension,
81
+ )
82
+ )
83
+ throw new Error(
84
+ `${flag} must be a .png, .svg, .gif, .jpg, .webp or .ico file.`,
85
+ );
86
+ if (fs.statSync(source).size > 512 * 1024)
87
+ throw new Error(`${flag} must be under 512KB; it is served on every visit.`);
88
+ return { source, extension };
89
+ };
90
+ const logo = brandImage(options.logo, "--logo");
91
+ const favicon = brandImage(options.favicon, "--favicon");
92
+ const dir = path.resolve(destination);
93
+ const appModule = options.module
94
+ ? inspectModule(options.module, options.moduleEntry, dir)
95
+ : null;
96
+ if (options.moduleEntry && !appModule)
97
+ throw new Error("--module-entry requires --module.");
98
+ if (options.replace && fs.existsSync(dir)) {
99
+ if (!fs.existsSync(path.join(dir, ".fidj-generated")))
100
+ throw new Error(
101
+ "Replacement requires a .fidj-generated marker in the destination.",
102
+ );
103
+ fs.rmSync(dir, { recursive: true });
104
+ }
105
+ if (fs.existsSync(dir) && fs.readdirSync(dir).length)
106
+ throw new Error(
107
+ "Destination must be empty. Existing projects are never overwritten.",
108
+ );
109
+ fs.mkdirSync(dir, { recursive: true });
110
+ fs.cpSync(templateRoot, dir, { recursive: true });
111
+ const pkg = JSON.parse(
112
+ fs.readFileSync(path.join(dir, "package.json"), "utf8"),
113
+ );
114
+ pkg.name = name;
115
+ if (options.sdkPath) {
116
+ const sdkPath = path.resolve(options.sdkPath);
117
+ const sdk = JSON.parse(
118
+ fs.readFileSync(path.join(sdkPath, "package.json"), "utf8"),
119
+ );
120
+ if (sdk.name !== "@ofidj/node")
121
+ throw new Error("SDK path must contain a built @ofidj/node package.");
122
+ pkg.dependencies["@ofidj/node"] = "file:" + sdkPath;
123
+ }
124
+ fs.writeFileSync(
125
+ path.join(dir, "package.json"),
126
+ JSON.stringify(pkg, null, 2) + "\n",
127
+ );
128
+ fs.renameSync(path.join(dir, "gitignore"), path.join(dir, ".gitignore"));
129
+ const settings = `FIDJ_APP_ID=${options.appId}\nFIDJ_API_ENDPOINT=${api.href.replace(/\/$/, "")}\nFIDJ_DASHBOARD_URL=https://fidj.ovh\nAPP_TITLE=${title}\nPORT=8200\nHOST=127.0.0.1\nLOCAL_DEMO=false\nFIDJ_DATA_DIR=../.fidj-data/${name}\nFIDJ_PRIVACY_ADAPTER_KEY=\n`;
130
+ const localApi = options.local
131
+ ? "http://localhost:3201/v3"
132
+ : api.href.replace(/\/$/, "");
133
+ const configuration = {
134
+ appId: options.appId,
135
+ oidcIssuer: options.oidcIssuer || null,
136
+ apiEndpoint: localApi,
137
+ dashboardUrl: options.local ? "http://localhost:4200" : "https://fidj.ovh",
138
+ title,
139
+ localDemo: Boolean(options.local),
140
+ allowAnonymous: anonymous === true || anonymous === "true",
141
+ welcome: options.welcome || title,
142
+ description:
143
+ options.description ||
144
+ "A space to explore, with an account that puts you in control.",
145
+ content: appModule ? "" : options.content,
146
+ highlights,
147
+ badges,
148
+ // An app that supplies no mark of its own borrows Fidj's, so the sign-in
149
+ // and the browser tab are never blank.
150
+ logo: logo ? "./brand/logo" + logo.extension : "./fidj-logo.png",
151
+ favicon: favicon ? "./brand/favicon" + favicon.extension : "./fidj-logo.png",
152
+ moduleEntry: appModule?.entry || "",
153
+ domain: options.domain,
154
+ };
155
+ if (logo || favicon) fs.mkdirSync(path.join(dir, "public/brand"), { recursive: true });
156
+ if (logo)
157
+ fs.copyFileSync(logo.source, path.join(dir, "public/brand/logo" + logo.extension));
158
+ if (favicon)
159
+ fs.copyFileSync(favicon.source, path.join(dir, "public/brand/favicon" + favicon.extension));
160
+ if (favicon) {
161
+ // The workspace starter serves a static index.html rather than rendering
162
+ // one, so its icon is rewritten here.
163
+ const page = path.join(dir, "public/index.html");
164
+ fs.writeFileSync(
165
+ page,
166
+ fs
167
+ .readFileSync(page, "utf8")
168
+ .replace('href="/fidj-logo.png"', `href="/brand/favicon${favicon.extension}"`),
169
+ );
170
+ }
171
+ if (appModule) {
172
+ fs.cpSync(appModule.root, path.join(dir, "public/module"), {
173
+ recursive: true,
174
+ });
175
+ const entryFile = path.join(dir, "public/module", appModule.file);
176
+ // Point back at the shell's directory, not at its index file, for the same
177
+ // reason the module entry does: no index.html in the address bar.
178
+ const toRoot =
179
+ path.posix.relative(
180
+ path.posix.dirname("module/" + appModule.file),
181
+ ".",
182
+ ) || ".";
183
+ const relativeSignin = toRoot + "/#/signin";
184
+ const html = fs.readFileSync(entryFile, "utf8");
185
+ if (!html.includes("</head>"))
186
+ throw new Error("Module entry must have an HTML head.");
187
+ fs.writeFileSync(
188
+ entryFile,
189
+ html.replace(
190
+ "</head>",
191
+ `<meta name="fidj-signin" content="${relativeSignin}"></head>`,
192
+ ),
193
+ );
194
+ }
195
+ fs.writeFileSync(
196
+ path.join(dir, "app.config.json"),
197
+ JSON.stringify(configuration, null, 2) + "\n",
198
+ );
199
+ const env = settings
200
+ .replace(api.href.replace(/\/$/, ""), localApi)
201
+ .replace(
202
+ "FIDJ_DASHBOARD_URL=https://fidj.ovh",
203
+ `FIDJ_DASHBOARD_URL=${configuration.dashboardUrl}`,
204
+ )
205
+ .replace("LOCAL_DEMO=false", `LOCAL_DEMO=${configuration.localDemo}`);
206
+ fs.writeFileSync(path.join(dir, ".env.example"), env);
207
+ fs.writeFileSync(path.join(dir, ".env"), env);
208
+ fs.writeFileSync(
209
+ path.join(dir, ".fidj-generated"),
210
+ "Disposable generator output.\n",
211
+ );
212
+ return dir;
213
+ }
214
+ module.exports = { scaffold };