@ofidj/generator-fidj 1.0.0 → 1.0.1
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 +93 -58
- package/bin/create-fidj.cjs +54 -0
- package/generators/app/index.js +58 -189
- package/generators/app/templates/typescript/README.md +73 -0
- package/generators/app/templates/typescript/app.config.json +10 -0
- package/generators/app/templates/typescript/gitignore +5 -0
- package/generators/app/templates/typescript/package.json +25 -0
- package/generators/app/templates/typescript/public/fidj-logo.png +0 -0
- package/generators/app/templates/typescript/public/index.html +16 -0
- package/generators/app/templates/typescript/scripts/build.mjs +46 -0
- package/generators/app/templates/typescript/scripts/privacy-check.mjs +21 -0
- package/generators/app/templates/typescript/scripts/render-content.mjs +14 -0
- package/generators/app/templates/typescript/server/data-store.ts +123 -0
- package/generators/app/templates/typescript/server/index.ts +329 -0
- package/generators/app/templates/typescript/server/privacy-adapter.ts +21 -0
- package/generators/app/templates/typescript/src/content.ts +416 -0
- package/generators/app/templates/typescript/src/main.ts +243 -0
- package/generators/app/templates/typescript/src/style.css +639 -0
- package/generators/app/templates/typescript/test/server.test.mjs +287 -0
- package/generators/app/templates/typescript/tsconfig.json +15 -0
- package/lib/app-module.cjs +59 -0
- package/lib/scaffold.cjs +145 -0
- package/package.json +18 -12
- package/generators/app/templates/app2018/.travis.yml +0 -35
- package/generators/app/templates/app2018/LICENSE +0 -21
- package/generators/app/templates/app2018/Procfile +0 -1
- package/generators/app/templates/app2018/README.md +0 -49
- package/generators/app/templates/app2018/config.xml +0 -91
- package/generators/app/templates/app2018/gulpfile.js +0 -63
- package/generators/app/templates/app2018/ionic.config.json +0 -9
- package/generators/app/templates/app2018/package.json +0 -90
- package/generators/app/templates/app2018/platforms/README.md +0 -1
- package/generators/app/templates/app2018/resources/README.md +0 -6
- package/generators/app/templates/app2018/resources/icon.png +0 -0
- package/generators/app/templates/app2018/resources/marketing-01-ipad.png +0 -0
- package/generators/app/templates/app2018/resources/marketing-01-iphone.png +0 -0
- package/generators/app/templates/app2018/resources/marketing-02-ipad.png +0 -0
- package/generators/app/templates/app2018/resources/marketing-02-iphone.png +0 -0
- package/generators/app/templates/app2018/resources/marketing-03-ipad.png +0 -0
- package/generators/app/templates/app2018/resources/marketing-03-iphone.png +0 -0
- package/generators/app/templates/app2018/resources/marketing.json +0 -19
- package/generators/app/templates/app2018/resources/splash.png +0 -0
- package/generators/app/templates/app2018/src/app/app.component.ts +0 -88
- package/generators/app/templates/app2018/src/app/app.html +0 -19
- package/generators/app/templates/app2018/src/app/app.module.ts +0 -40
- package/generators/app/templates/app2018/src/app/app.scss +0 -16
- package/generators/app/templates/app2018/src/app/main.ts +0 -5
- package/generators/app/templates/app2018/src/app/shared/card.model.ts +0 -10
- package/generators/app/templates/app2018/src/app/shared/profile.service.ts +0 -46
- package/generators/app/templates/app2018/src/app/shared/shared.module.ts +0 -35
- package/generators/app/templates/app2018/src/app/shared/storage.service.ts +0 -11
- package/generators/app/templates/app2018/src/app/shared/version.const.ts +0 -5
- package/generators/app/templates/app2018/src/assets/icon/favicon.ico +0 -0
- package/generators/app/templates/app2018/src/assets/imgs/gg.png +0 -0
- package/generators/app/templates/app2018/src/assets/imgs/github.png +0 -0
- package/generators/app/templates/app2018/src/assets/imgs/login.svg +0 -5668
- package/generators/app/templates/app2018/src/assets/imgs/logo.png +0 -0
- package/generators/app/templates/app2018/src/index.html +0 -49
- package/generators/app/templates/app2018/src/manifest.json +0 -13
- package/generators/app/templates/app2018/src/pages/home/home.html +0 -56
- package/generators/app/templates/app2018/src/pages/home/home.scss +0 -3
- package/generators/app/templates/app2018/src/pages/home/home.ts +0 -126
- package/generators/app/templates/app2018/src/pages/login/login.html +0 -56
- package/generators/app/templates/app2018/src/pages/login/login.scss +0 -44
- package/generators/app/templates/app2018/src/pages/login/login.ts +0 -59
- package/generators/app/templates/app2018/src/pages/profile/profile.html +0 -33
- package/generators/app/templates/app2018/src/pages/profile/profile.scss +0 -3
- package/generators/app/templates/app2018/src/pages/profile/profile.ts +0 -36
- package/generators/app/templates/app2018/src/service-worker.js +0 -31
- package/generators/app/templates/app2018/src/theme/variables.scss +0 -88
- package/generators/app/templates/app2018/tests/karma.unit-tests.conf.js +0 -84
- package/generators/app/templates/app2018/tests/unit/app.test.js +0 -10
- package/generators/app/templates/app2018/tsconfig.json +0 -28
- package/generators/app/templates/app2018/tslint.json +0 -8
- package/generators/app/templates/app2018/web.js +0 -21
- package/generators/app/templates/app2021/.travis.yml +0 -32
- package/generators/app/templates/app2021/LICENSE +0 -21
- package/generators/app/templates/app2021/Procfile +0 -1
- package/generators/app/templates/app2021/README.md +0 -45
- package/generators/app/templates/app2021/angular.json +0 -188
- package/generators/app/templates/app2021/browserslist +0 -12
- package/generators/app/templates/app2021/ionic.config.json +0 -9
- package/generators/app/templates/app2021/karma.conf.js +0 -31
- package/generators/app/templates/app2021/package-lock.json +0 -5
- package/generators/app/templates/app2021/package.json +0 -77
- package/generators/app/templates/app2021/platforms/README.md +0 -1
- package/generators/app/templates/app2021/resources/README.md +0 -6
- package/generators/app/templates/app2021/resources/icon.png +0 -0
- package/generators/app/templates/app2021/resources/marketing-01-ipad.png +0 -0
- package/generators/app/templates/app2021/resources/marketing-01-iphone.png +0 -0
- package/generators/app/templates/app2021/resources/marketing-02-ipad.png +0 -0
- package/generators/app/templates/app2021/resources/marketing-02-iphone.png +0 -0
- package/generators/app/templates/app2021/resources/marketing-03-ipad.png +0 -0
- package/generators/app/templates/app2021/resources/marketing-03-iphone.png +0 -0
- package/generators/app/templates/app2021/resources/marketing.json +0 -19
- package/generators/app/templates/app2021/resources/splash.png +0 -0
- package/generators/app/templates/app2021/src/app/app-routing.module.ts +0 -25
- package/generators/app/templates/app2021/src/app/app.component.html +0 -3
- package/generators/app/templates/app2021/src/app/app.component.scss +0 -0
- package/generators/app/templates/app2021/src/app/app.component.spec.ts +0 -50
- package/generators/app/templates/app2021/src/app/app.component.ts +0 -28
- package/generators/app/templates/app2021/src/app/app.module.ts +0 -24
- package/generators/app/templates/app2021/src/app/content/content-routing.module.ts +0 -16
- package/generators/app/templates/app2021/src/app/content/content.module.ts +0 -20
- package/generators/app/templates/app2021/src/app/content/content.page.html +0 -31
- package/generators/app/templates/app2021/src/app/content/content.page.scss +0 -0
- package/generators/app/templates/app2021/src/app/content/content.page.ts +0 -26
- package/generators/app/templates/app2021/src/app/explore-container/explore-container.component.html +0 -4
- package/generators/app/templates/app2021/src/app/explore-container/explore-container.component.scss +0 -4
- package/generators/app/templates/app2021/src/app/explore-container/explore-container.component.spec.ts +0 -24
- package/generators/app/templates/app2021/src/app/explore-container/explore-container.component.ts +0 -16
- package/generators/app/templates/app2021/src/app/explore-container/explore-container.module.ts +0 -14
- package/generators/app/templates/app2021/src/app/my/my-routing.module.ts +0 -36
- package/generators/app/templates/app2021/src/app/my/my.module.ts +0 -19
- package/generators/app/templates/app2021/src/app/my/my.page.html +0 -15
- package/generators/app/templates/app2021/src/app/my/my.page.scss +0 -1
- package/generators/app/templates/app2021/src/app/my/my.page.spec.ts +0 -26
- package/generators/app/templates/app2021/src/app/my/my.page.ts +0 -25
- package/generators/app/templates/app2021/src/app/profile/profile-routing.module.ts +0 -16
- package/generators/app/templates/app2021/src/app/profile/profile.module.ts +0 -20
- package/generators/app/templates/app2021/src/app/profile/profile.page.html +0 -49
- package/generators/app/templates/app2021/src/app/profile/profile.page.scss +0 -0
- package/generators/app/templates/app2021/src/app/profile/profile.page.spec.ts +0 -25
- package/generators/app/templates/app2021/src/app/profile/profile.page.ts +0 -44
- package/generators/app/templates/app2021/src/app/shared/const.ts +0 -3
- package/generators/app/templates/app2021/src/app/signin/signin-routing.module.ts +0 -16
- package/generators/app/templates/app2021/src/app/signin/signin.module.ts +0 -18
- package/generators/app/templates/app2021/src/app/signin/signin.page.html +0 -53
- package/generators/app/templates/app2021/src/app/signin/signin.page.scss +0 -36
- package/generators/app/templates/app2021/src/app/signin/signin.page.ts +0 -69
- package/generators/app/templates/app2021/src/assets/icon/favicon.png +0 -0
- package/generators/app/templates/app2021/src/assets/shapes.svg +0 -1
- package/generators/app/templates/app2021/src/environments/environment.prod.ts +0 -3
- package/generators/app/templates/app2021/src/environments/environment.ts +0 -16
- package/generators/app/templates/app2021/src/global.scss +0 -26
- package/generators/app/templates/app2021/src/index.html +0 -26
- package/generators/app/templates/app2021/src/main.ts +0 -12
- package/generators/app/templates/app2021/src/polyfills.ts +0 -67
- package/generators/app/templates/app2021/src/test.ts +0 -20
- package/generators/app/templates/app2021/src/theme/variables.scss +0 -232
- package/generators/app/templates/app2021/src/zone-flags.ts +0 -5
- package/generators/app/templates/app2021/tests/protractor.conf.js +0 -36
- package/generators/app/templates/app2021/tests/src/app.e2e-spec.ts +0 -14
- package/generators/app/templates/app2021/tests/src/app.po.ts +0 -11
- package/generators/app/templates/app2021/tests/tsconfig.json +0 -13
- package/generators/app/templates/app2021/tsconfig.app.json +0 -18
- package/generators/app/templates/app2021/tsconfig.json +0 -23
- package/generators/app/templates/app2021/tsconfig.spec.json +0 -18
- package/generators/app/templates/app2021/tslint.json +0 -149
- package/generators/app/templates/app2021/web.js +0 -21
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { build } from "esbuild";
|
|
2
|
+
import { cp, mkdir, rm, readFile, writeFile, readdir } from "node:fs/promises";
|
|
3
|
+
const config = JSON.parse(await readFile("app.config.json", "utf8"));
|
|
4
|
+
const contentApp = typeof config.content === "string";
|
|
5
|
+
await rm("dist", { recursive: true, force: true });
|
|
6
|
+
await mkdir("dist/public", { recursive: true });
|
|
7
|
+
await cp("public", "dist/public", { recursive: true });
|
|
8
|
+
await Promise.all([
|
|
9
|
+
build({
|
|
10
|
+
entryPoints: [contentApp ? "src/content.ts" : "src/main.ts"],
|
|
11
|
+
bundle: true,
|
|
12
|
+
platform: "browser",
|
|
13
|
+
target: "es2022",
|
|
14
|
+
outfile: "dist/public/main.js",
|
|
15
|
+
sourcemap: true,
|
|
16
|
+
}),
|
|
17
|
+
build({
|
|
18
|
+
entryPoints: ["server/index.ts"],
|
|
19
|
+
bundle: true,
|
|
20
|
+
packages: "external",
|
|
21
|
+
platform: "node",
|
|
22
|
+
target: "node22",
|
|
23
|
+
format: "cjs",
|
|
24
|
+
outfile: "dist/server.cjs",
|
|
25
|
+
}),
|
|
26
|
+
]);
|
|
27
|
+
if (contentApp) {
|
|
28
|
+
const { renderContent } = await import("./render-content.mjs");
|
|
29
|
+
await writeFile("dist/public/index.html", renderContent(config));
|
|
30
|
+
await rm("www", { recursive: true, force: true });
|
|
31
|
+
await cp("dist/public", "www", { recursive: true });
|
|
32
|
+
if (config.domain) await writeFile("www/CNAME", config.domain + "\n");
|
|
33
|
+
}
|
|
34
|
+
await cp("dist/public/index.html", "dist/public/app.html");
|
|
35
|
+
|
|
36
|
+
const moduleFiles = {};
|
|
37
|
+
async function collect(directory, relative = "") {
|
|
38
|
+
for (const item of await readdir(directory, { withFileTypes: true })) {
|
|
39
|
+
const name = relative + item.name;
|
|
40
|
+
if (item.isDirectory())
|
|
41
|
+
await collect(directory + "/" + item.name, name + "/");
|
|
42
|
+
else moduleFiles["/" + name] = name;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
await collect("dist/public");
|
|
46
|
+
await writeFile("dist/public-files.json", JSON.stringify(moduleFiles));
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import {createHmac, randomUUID} from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
try {
|
|
4
|
+
const key = process.env.FIDJ_PRIVACY_ADAPTER_KEY;
|
|
5
|
+
const appId = process.env.FIDJ_APP_ID;
|
|
6
|
+
if (!appId || !key || key.length < 32) throw new Error('Set FIDJ_APP_ID and a private FIDJ_PRIVACY_ADAPTER_KEY of at least 32 characters in .env.');
|
|
7
|
+
const url = new URL(process.env.FIDJ_APP_URL || `http://127.0.0.1:${process.env.PORT || 8200}`);
|
|
8
|
+
if (url.username || url.password || url.search || url.hash || (url.protocol !== 'https:' && !(url.protocol === 'http:' && ['localhost', '127.0.0.1'].includes(url.hostname)))) throw new Error('Use a trusted HTTPS app URL, or loopback HTTP for development.');
|
|
9
|
+
url.pathname = url.pathname.replace(/\/$/, '') + '/fidj/privacy';
|
|
10
|
+
const timestamp = String(Date.now());
|
|
11
|
+
const data = {appId, subject: 'readiness-check', operation: 'check', requestId: randomUUID()};
|
|
12
|
+
const text = JSON.stringify(data);
|
|
13
|
+
const response = await fetch(url, {method: 'POST', headers: {'Content-Type': 'application/json', 'x-fidj-timestamp': timestamp, 'x-fidj-signature': createHmac('sha256', key).update(timestamp + '.' + text).digest('hex')}, body: text, signal: AbortSignal.timeout(5000), redirect: 'error'});
|
|
14
|
+
if (!response.ok) throw new Error('The app-data handler could not be verified. Check the URL, shared secret and server.');
|
|
15
|
+
const result = await response.json();
|
|
16
|
+
if (result.requestId !== data.requestId || result.status !== 'completed' || result.storage !== 'ready' || !['export', 'erase'].every(capability => result.capabilities?.includes(capability))) throw new Error('The handler did not confirm both operations and writable storage.');
|
|
17
|
+
console.log('Ready: app-data export, erasure and writable storage verified. No user data was created or erased.');
|
|
18
|
+
} catch (error) {
|
|
19
|
+
console.error(error.message || 'The readiness check failed.');
|
|
20
|
+
process.exitCode = 1;
|
|
21
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
const escape = (value) =>
|
|
2
|
+
String(value ?? "").replace(
|
|
3
|
+
/[&<>"']/g,
|
|
4
|
+
(char) =>
|
|
5
|
+
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[
|
|
6
|
+
char
|
|
7
|
+
],
|
|
8
|
+
);
|
|
9
|
+
export function renderContent(config) {
|
|
10
|
+
// HTML is supplied by the developer at generation time, never by an app visitor.
|
|
11
|
+
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="./fidj-logo.png"><link rel="stylesheet" href="./main.css"></head><body>
|
|
12
|
+
<header class="topbar"><a class="brand" href="#/content">${escape(config.title)}</a><a class="fidj-brand" href="${escape(config.moduleEntry || config.dashboardUrl + "/#/my")}"><img src="./fidj-logo.png" alt="Fidj">My privacy ↗</a></header>
|
|
13
|
+
<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>`;
|
|
14
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import {
|
|
2
|
+
mkdir,
|
|
3
|
+
readFile,
|
|
4
|
+
open,
|
|
5
|
+
rename,
|
|
6
|
+
readdir,
|
|
7
|
+
unlink,
|
|
8
|
+
} from "node:fs/promises";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
11
|
+
|
|
12
|
+
export interface Note {
|
|
13
|
+
id: string;
|
|
14
|
+
title: string;
|
|
15
|
+
body: string;
|
|
16
|
+
createdAt: string;
|
|
17
|
+
}
|
|
18
|
+
interface State {
|
|
19
|
+
notes: Record<string, Note[]>;
|
|
20
|
+
receipts: Record<string, { subjectHash: string; completedAt: string }>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// One writer process per directory. Replace this adapter with your database for multi-instance hosting.
|
|
24
|
+
export class DataStore {
|
|
25
|
+
private queue: Promise<unknown> = Promise.resolve();
|
|
26
|
+
constructor(
|
|
27
|
+
private directory: string,
|
|
28
|
+
private appId = "",
|
|
29
|
+
) {}
|
|
30
|
+
private async state(): Promise<State> {
|
|
31
|
+
try {
|
|
32
|
+
return JSON.parse(
|
|
33
|
+
await readFile(join(this.directory, "data.json"), "utf8"),
|
|
34
|
+
);
|
|
35
|
+
} catch (error) {
|
|
36
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT")
|
|
37
|
+
return { notes: {}, receipts: {} };
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
private transact<T>(task: (state: State) => T | Promise<T>): Promise<T> {
|
|
42
|
+
const operation = this.queue.then(async () => {
|
|
43
|
+
await mkdir(this.directory, { recursive: true, mode: 0o700 });
|
|
44
|
+
// Interrupted writes must not leave a second copy of erased data behind.
|
|
45
|
+
for (const name of await readdir(this.directory)) {
|
|
46
|
+
if (/^[a-f0-9-]{36}\.tmp$/.test(name))
|
|
47
|
+
await unlink(join(this.directory, name));
|
|
48
|
+
}
|
|
49
|
+
const state = await this.state();
|
|
50
|
+
for (const [id, receipt] of Object.entries(state.receipts)) {
|
|
51
|
+
if (Date.parse(receipt.completedAt) < Date.now() - 30 * 86400000)
|
|
52
|
+
delete state.receipts[id];
|
|
53
|
+
}
|
|
54
|
+
const result = await task(state);
|
|
55
|
+
await mkdir(this.directory, { recursive: true, mode: 0o700 });
|
|
56
|
+
const temporary = join(this.directory, randomUUID() + ".tmp");
|
|
57
|
+
try {
|
|
58
|
+
const file = await open(temporary, "wx", 0o600);
|
|
59
|
+
try {
|
|
60
|
+
await file.writeFile(JSON.stringify(state));
|
|
61
|
+
await file.sync();
|
|
62
|
+
} finally {
|
|
63
|
+
await file.close();
|
|
64
|
+
}
|
|
65
|
+
await rename(temporary, join(this.directory, "data.json"));
|
|
66
|
+
} finally {
|
|
67
|
+
await unlink(temporary).catch((error) => {
|
|
68
|
+
if (error.code !== "ENOENT") throw error;
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
const directory = await open(this.directory, "r");
|
|
72
|
+
try {
|
|
73
|
+
await directory.sync();
|
|
74
|
+
} finally {
|
|
75
|
+
await directory.close();
|
|
76
|
+
}
|
|
77
|
+
return result;
|
|
78
|
+
});
|
|
79
|
+
this.queue = operation.catch(() => {});
|
|
80
|
+
return operation;
|
|
81
|
+
}
|
|
82
|
+
async check() {
|
|
83
|
+
await this.transact(() => undefined);
|
|
84
|
+
return {storage: "ready", capabilities: ["export", "erase"]};
|
|
85
|
+
}
|
|
86
|
+
async notes(subject: string): Promise<Note[]> {
|
|
87
|
+
await this.queue;
|
|
88
|
+
return (await this.state()).notes[this.key(subject)] || [];
|
|
89
|
+
}
|
|
90
|
+
add(subject: string, note: Note, authorize: () => Promise<unknown>) {
|
|
91
|
+
return this.transact(async (state) => {
|
|
92
|
+
await authorize();
|
|
93
|
+
const key = this.key(subject);
|
|
94
|
+
const notes = state.notes[key] || [];
|
|
95
|
+
if (notes.length >= 100) throw new Error("Note limit reached.");
|
|
96
|
+
state.notes[key] = [...notes, note];
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
erase(subject: string, requestId: string) {
|
|
100
|
+
return this.transact((state) => {
|
|
101
|
+
const subjectHash = this.key(subject);
|
|
102
|
+
const existing = state.receipts[requestId];
|
|
103
|
+
if (existing && existing.subjectHash !== subjectHash)
|
|
104
|
+
throw new Error("Receipt identity mismatch.");
|
|
105
|
+
// Re-delivery of a completed request must not erase data from a later membership.
|
|
106
|
+
if (existing)
|
|
107
|
+
return {
|
|
108
|
+
requestId,
|
|
109
|
+
status: "completed",
|
|
110
|
+
completedAt: existing.completedAt,
|
|
111
|
+
};
|
|
112
|
+
delete state.notes[subjectHash];
|
|
113
|
+
const completedAt = new Date().toISOString();
|
|
114
|
+
state.receipts[requestId] = { subjectHash, completedAt };
|
|
115
|
+
return { requestId, status: "completed", completedAt };
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
private key(subject: string) {
|
|
119
|
+
return createHash("sha256")
|
|
120
|
+
.update(this.appId + ":" + subject)
|
|
121
|
+
.digest("hex");
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import { createServer, IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { join, extname } from "node:path";
|
|
4
|
+
import { DataStore } from "./data-store";
|
|
5
|
+
import { verifyPrivacyRequest } from "./privacy-adapter";
|
|
6
|
+
export { DataStore } from "./data-store";
|
|
7
|
+
import { verifyAppSession, SessionVerificationError } from "@ofidj/node";
|
|
8
|
+
|
|
9
|
+
export interface Settings {
|
|
10
|
+
appId: string;
|
|
11
|
+
apiEndpoint: string;
|
|
12
|
+
dashboardUrl: string;
|
|
13
|
+
title: string;
|
|
14
|
+
localDemo: boolean;
|
|
15
|
+
}
|
|
16
|
+
class HttpError extends Error {
|
|
17
|
+
constructor(
|
|
18
|
+
public status: number,
|
|
19
|
+
message: string,
|
|
20
|
+
) {
|
|
21
|
+
super(message);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function body(req: IncomingMessage): Promise<any> {
|
|
26
|
+
let value = "";
|
|
27
|
+
for await (const chunk of req) {
|
|
28
|
+
value += chunk;
|
|
29
|
+
if (Buffer.byteLength(value) > 16384)
|
|
30
|
+
throw new HttpError(413, "Request too large.");
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
return JSON.parse(value || "{}");
|
|
34
|
+
} catch {
|
|
35
|
+
throw new HttpError(400, "Invalid JSON.");
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function json(res: ServerResponse, status: number, value: unknown) {
|
|
39
|
+
res.writeHead(status, {
|
|
40
|
+
"Content-Type": "application/json",
|
|
41
|
+
"Cache-Control": "no-store",
|
|
42
|
+
"X-Content-Type-Options": "nosniff",
|
|
43
|
+
});
|
|
44
|
+
res.end(JSON.stringify(value));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function createApp(
|
|
48
|
+
settings: Settings,
|
|
49
|
+
options: { dataDir?: string; adapterKey?: string; store?: DataStore } = {},
|
|
50
|
+
) {
|
|
51
|
+
const store =
|
|
52
|
+
options.store ||
|
|
53
|
+
new DataStore(
|
|
54
|
+
options.dataDir || join(process.cwd(), ".fidj-data"),
|
|
55
|
+
settings.appId,
|
|
56
|
+
);
|
|
57
|
+
const upstream = async (
|
|
58
|
+
token: string,
|
|
59
|
+
suffix: string,
|
|
60
|
+
method = "GET",
|
|
61
|
+
data?: unknown,
|
|
62
|
+
) => {
|
|
63
|
+
let response: Response;
|
|
64
|
+
try {
|
|
65
|
+
response = await fetch(
|
|
66
|
+
`${settings.apiEndpoint}/me/apps/${encodeURIComponent(settings.appId)}${suffix}`,
|
|
67
|
+
{
|
|
68
|
+
method,
|
|
69
|
+
headers: {
|
|
70
|
+
Authorization: `Bearer ${token}`,
|
|
71
|
+
"Content-Type": "application/json",
|
|
72
|
+
},
|
|
73
|
+
body: data === undefined ? undefined : JSON.stringify(data),
|
|
74
|
+
signal: AbortSignal.timeout(5000),
|
|
75
|
+
redirect: "error",
|
|
76
|
+
},
|
|
77
|
+
);
|
|
78
|
+
} catch {
|
|
79
|
+
throw new HttpError(503, "Fidj is unavailable. Please retry.");
|
|
80
|
+
}
|
|
81
|
+
const result = await response.json().catch(() => ({}));
|
|
82
|
+
if (!response.ok)
|
|
83
|
+
throw new HttpError(
|
|
84
|
+
response.status,
|
|
85
|
+
result.status || "Fidj could not complete this action.",
|
|
86
|
+
);
|
|
87
|
+
return { status: response.status, data: result };
|
|
88
|
+
};
|
|
89
|
+
return createServer(async (req, res) => {
|
|
90
|
+
try {
|
|
91
|
+
const pathname = new URL(req.url || "/", "http://localhost").pathname;
|
|
92
|
+
if (pathname === "/fidj/privacy" && req.method === "POST") {
|
|
93
|
+
const input = await body(req);
|
|
94
|
+
if (
|
|
95
|
+
!verifyPrivacyRequest(
|
|
96
|
+
options.adapterKey,
|
|
97
|
+
String(req.headers["x-fidj-timestamp"] || ""),
|
|
98
|
+
String(req.headers["x-fidj-signature"] || ""),
|
|
99
|
+
input,
|
|
100
|
+
)
|
|
101
|
+
)
|
|
102
|
+
throw new HttpError(401, "Invalid privacy request.");
|
|
103
|
+
if (
|
|
104
|
+
input.appId !== settings.appId ||
|
|
105
|
+
typeof input.subject !== "string" ||
|
|
106
|
+
!input.subject ||
|
|
107
|
+
input.subject.length > 200 ||
|
|
108
|
+
typeof input.requestId !== "string" ||
|
|
109
|
+
!/^[a-zA-Z0-9-]{1,100}$/.test(input.requestId)
|
|
110
|
+
)
|
|
111
|
+
throw new HttpError(400, "Invalid privacy scope.");
|
|
112
|
+
if (input.operation === "check") return json(res, 200, {requestId: input.requestId, status: "completed", ...await store.check()});
|
|
113
|
+
if (input.operation === "export")
|
|
114
|
+
return json(res, 200, {
|
|
115
|
+
requestId: input.requestId,
|
|
116
|
+
status: "completed",
|
|
117
|
+
data: { notes: await store.notes(input.subject) },
|
|
118
|
+
coverage: "Notes stored by this app",
|
|
119
|
+
});
|
|
120
|
+
if (input.operation === "erase")
|
|
121
|
+
return json(
|
|
122
|
+
res,
|
|
123
|
+
200,
|
|
124
|
+
await store.erase(input.subject, input.requestId),
|
|
125
|
+
);
|
|
126
|
+
throw new HttpError(400, "Unsupported privacy operation.");
|
|
127
|
+
}
|
|
128
|
+
if (pathname === "/api/config" && req.method === "GET")
|
|
129
|
+
return json(res, 200, settings);
|
|
130
|
+
if (pathname === "/api/health" && req.method === "GET")
|
|
131
|
+
return json(res, 200, { status: "ready" });
|
|
132
|
+
if (pathname.startsWith("/api/")) {
|
|
133
|
+
const authorization = req.headers.authorization || "";
|
|
134
|
+
if (!authorization.startsWith("Bearer "))
|
|
135
|
+
throw new HttpError(401, "Sign in to continue.");
|
|
136
|
+
const token = authorization.slice(7);
|
|
137
|
+
const session = await verifyAppSession(token, settings);
|
|
138
|
+
if (pathname === "/api/session" && req.method === "GET")
|
|
139
|
+
return json(res, 200, session);
|
|
140
|
+
if (pathname === "/api/notes" && req.method === "GET")
|
|
141
|
+
return json(res, 200, { notes: await store.notes(session.subject) });
|
|
142
|
+
if (pathname === "/api/notes" && req.method === "POST") {
|
|
143
|
+
if (!session.roles.some((role) => ["Owner", "Editor"].includes(role)))
|
|
144
|
+
throw new HttpError(
|
|
145
|
+
403,
|
|
146
|
+
"An Editor or Owner role is required to save a note.",
|
|
147
|
+
);
|
|
148
|
+
const input = await body(req);
|
|
149
|
+
if (
|
|
150
|
+
typeof input.title !== "string" ||
|
|
151
|
+
!input.title.trim() ||
|
|
152
|
+
input.title.length > 120 ||
|
|
153
|
+
typeof input.body !== "string" ||
|
|
154
|
+
input.body.length > 5000
|
|
155
|
+
)
|
|
156
|
+
throw new HttpError(
|
|
157
|
+
400,
|
|
158
|
+
"Add a title (up to 120 characters) and a note (up to 5,000 characters).",
|
|
159
|
+
);
|
|
160
|
+
const current = await store.notes(session.subject);
|
|
161
|
+
if (current.length >= 100)
|
|
162
|
+
throw new HttpError(
|
|
163
|
+
409,
|
|
164
|
+
"This starter supports 100 notes per person.",
|
|
165
|
+
);
|
|
166
|
+
const note = {
|
|
167
|
+
id: crypto.randomUUID(),
|
|
168
|
+
title: input.title.trim(),
|
|
169
|
+
body: input.body,
|
|
170
|
+
createdAt: new Date().toISOString(),
|
|
171
|
+
};
|
|
172
|
+
await store.add(session.subject, note, () =>
|
|
173
|
+
verifyAppSession(token, settings),
|
|
174
|
+
);
|
|
175
|
+
return json(res, 201, { note });
|
|
176
|
+
}
|
|
177
|
+
if (pathname === "/api/privacy" && req.method === "GET") {
|
|
178
|
+
const [consent, history] = await Promise.all([
|
|
179
|
+
upstream(token, "/consents"),
|
|
180
|
+
upstream(token, "/consents/history"),
|
|
181
|
+
]);
|
|
182
|
+
return json(res, 200, {
|
|
183
|
+
consent: consent.data,
|
|
184
|
+
history: history.data.history,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
if (pathname === "/api/privacy" && req.method === "PUT") {
|
|
188
|
+
const input = await body(req);
|
|
189
|
+
const data: Record<string, unknown> = { source: "profile" };
|
|
190
|
+
for (const purpose of [
|
|
191
|
+
"terms",
|
|
192
|
+
"analytics",
|
|
193
|
+
"communications",
|
|
194
|
+
"optionalData",
|
|
195
|
+
])
|
|
196
|
+
if (input[purpose] !== undefined) data[purpose] = input[purpose];
|
|
197
|
+
if (input.terms === true) data.termsVersion = "starter-demo-1";
|
|
198
|
+
const result = await upstream(token, "/consents", "PUT", data);
|
|
199
|
+
return json(res, result.status, result.data);
|
|
200
|
+
}
|
|
201
|
+
if (pathname === "/api/privacy/export" && req.method === "GET") {
|
|
202
|
+
const identity = await upstream(token, "/export");
|
|
203
|
+
return json(res, 200, {
|
|
204
|
+
exportedAt: new Date().toISOString(),
|
|
205
|
+
fidj: identity.data,
|
|
206
|
+
app: identity.data.applicationData || {
|
|
207
|
+
notes: await store.notes(session.subject),
|
|
208
|
+
},
|
|
209
|
+
coverage:
|
|
210
|
+
"Fidj records for this membership plus this app’s persisted notes. No other apps or independent external systems.",
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
if (pathname === "/api/privacy/leave" && req.method === "DELETE") {
|
|
214
|
+
const input = await body(req);
|
|
215
|
+
if (input.confirm !== settings.appId)
|
|
216
|
+
throw new HttpError(400, "Confirm this app before leaving.");
|
|
217
|
+
const connection = await upstream(token, "/consents");
|
|
218
|
+
if (!connection.data.appDataConnected)
|
|
219
|
+
throw new HttpError(
|
|
220
|
+
409,
|
|
221
|
+
"The app owner must connect the data-erasure handler before departure can include your notes.",
|
|
222
|
+
);
|
|
223
|
+
const result = await upstream(token, "", "DELETE", {
|
|
224
|
+
confirm: settings.appId,
|
|
225
|
+
});
|
|
226
|
+
return json(res, result.status, {
|
|
227
|
+
...result.data,
|
|
228
|
+
appNotes: result.data.appData || "not_connected",
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
throw new HttpError(404, "Unknown operation.");
|
|
232
|
+
}
|
|
233
|
+
if (req.method !== "GET" && req.method !== "HEAD")
|
|
234
|
+
throw new HttpError(405, "Method not allowed.");
|
|
235
|
+
const files: Record<string, [string, string]> = {
|
|
236
|
+
"/": ["index.html", "text/html"],
|
|
237
|
+
"/index.html": ["index.html", "text/html"],
|
|
238
|
+
"/app": ["app.html", "text/html"],
|
|
239
|
+
"/hero.gif": ["hero.gif", "image/gif"],
|
|
240
|
+
"/main.js": ["main.js", "text/javascript"],
|
|
241
|
+
"/main.css": ["main.css", "text/css"],
|
|
242
|
+
"/fidj-logo.png": ["fidj-logo.png", "image/png"],
|
|
243
|
+
};
|
|
244
|
+
if (!files[pathname] && pathname.startsWith("/module/")) {
|
|
245
|
+
const manifest = JSON.parse(
|
|
246
|
+
await readFile(join(__dirname, "public-files.json"), "utf8"),
|
|
247
|
+
);
|
|
248
|
+
const asset =
|
|
249
|
+
manifest[pathname] ||
|
|
250
|
+
(pathname.endsWith("/")
|
|
251
|
+
? manifest[pathname + "index.html"]
|
|
252
|
+
: undefined);
|
|
253
|
+
const types: Record<string, string> = {
|
|
254
|
+
".html": "text/html",
|
|
255
|
+
".js": "text/javascript",
|
|
256
|
+
".css": "text/css",
|
|
257
|
+
".json": "application/json",
|
|
258
|
+
".png": "image/png",
|
|
259
|
+
".svg": "image/svg+xml",
|
|
260
|
+
".gif": "image/gif",
|
|
261
|
+
".ico": "image/x-icon",
|
|
262
|
+
".woff": "font/woff",
|
|
263
|
+
".woff2": "font/woff2",
|
|
264
|
+
};
|
|
265
|
+
if (typeof asset === "string")
|
|
266
|
+
files[pathname] = [
|
|
267
|
+
asset,
|
|
268
|
+
types[extname(asset)] || "application/octet-stream",
|
|
269
|
+
];
|
|
270
|
+
}
|
|
271
|
+
const file = files[pathname];
|
|
272
|
+
if (!file) throw new HttpError(404, "Not found.");
|
|
273
|
+
const content = await readFile(join(__dirname, "public", file[0]));
|
|
274
|
+
const apiOrigin = new URL(settings.apiEndpoint).origin;
|
|
275
|
+
res.writeHead(200, {
|
|
276
|
+
"Content-Type": file[1],
|
|
277
|
+
"X-Content-Type-Options": "nosniff",
|
|
278
|
+
"Referrer-Policy": "no-referrer",
|
|
279
|
+
"Content-Security-Policy": `default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' https:; connect-src 'self' ${apiOrigin}; object-src 'none'; base-uri 'none'; frame-ancestors 'none'`,
|
|
280
|
+
});
|
|
281
|
+
res.end(req.method === "HEAD" ? undefined : content);
|
|
282
|
+
} catch (error) {
|
|
283
|
+
const status =
|
|
284
|
+
error instanceof HttpError || error instanceof SessionVerificationError
|
|
285
|
+
? error.status
|
|
286
|
+
: 500;
|
|
287
|
+
json(res, status, {
|
|
288
|
+
message:
|
|
289
|
+
status === 500
|
|
290
|
+
? "The app could not complete this action."
|
|
291
|
+
: (error as Error).message,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (require.main === module) {
|
|
298
|
+
const appId = process.env.FIDJ_APP_ID;
|
|
299
|
+
if (!appId || !process.env.FIDJ_API_ENDPOINT)
|
|
300
|
+
throw new Error("Set FIDJ_APP_ID and FIDJ_API_ENDPOINT in .env.");
|
|
301
|
+
const apiEndpoint = process.env.FIDJ_API_ENDPOINT.replace(/\/$/, "");
|
|
302
|
+
const api = new URL(apiEndpoint);
|
|
303
|
+
if (
|
|
304
|
+
!["https:", "http:"].includes(api.protocol) ||
|
|
305
|
+
api.username ||
|
|
306
|
+
api.password ||
|
|
307
|
+
api.search ||
|
|
308
|
+
api.hash
|
|
309
|
+
)
|
|
310
|
+
throw new Error("Invalid Fidj API URL.");
|
|
311
|
+
const port = Number(process.env.PORT || 8200);
|
|
312
|
+
const host = process.env.HOST || "127.0.0.1";
|
|
313
|
+
const settings = {
|
|
314
|
+
appId,
|
|
315
|
+
apiEndpoint,
|
|
316
|
+
dashboardUrl: process.env.FIDJ_DASHBOARD_URL || "https://fidj.ovh",
|
|
317
|
+
title: process.env.APP_TITLE || "My workspace",
|
|
318
|
+
localDemo:
|
|
319
|
+
process.env.LOCAL_DEMO === "true" &&
|
|
320
|
+
host === "127.0.0.1" &&
|
|
321
|
+
["localhost", "127.0.0.1"].includes(api.hostname),
|
|
322
|
+
};
|
|
323
|
+
createApp(settings, {
|
|
324
|
+
dataDir: process.env.FIDJ_DATA_DIR,
|
|
325
|
+
adapterKey: process.env.FIDJ_PRIVACY_ADAPTER_KEY,
|
|
326
|
+
}).listen(port, host, () =>
|
|
327
|
+
console.log(`${settings.title}: http://${host}:${port}`),
|
|
328
|
+
);
|
|
329
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export function verifyPrivacyRequest(
|
|
4
|
+
key: string | undefined,
|
|
5
|
+
timestamp: string,
|
|
6
|
+
signature: string,
|
|
7
|
+
payload: unknown,
|
|
8
|
+
) {
|
|
9
|
+
if (
|
|
10
|
+
!key ||
|
|
11
|
+
key.length < 32 ||
|
|
12
|
+
!/^\d+$/.test(timestamp) ||
|
|
13
|
+
Math.abs(Date.now() - Number(timestamp)) > 300000 ||
|
|
14
|
+
!/^[a-f0-9]{64}$/.test(signature)
|
|
15
|
+
)
|
|
16
|
+
return false;
|
|
17
|
+
const expected = createHmac("sha256", key)
|
|
18
|
+
.update(timestamp + "." + JSON.stringify(payload))
|
|
19
|
+
.digest();
|
|
20
|
+
return timingSafeEqual(expected, Buffer.from(signature, "hex"));
|
|
21
|
+
}
|