@rocapine/community 0.1.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.
Files changed (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +118 -0
  3. package/lib/commands/adopt.d.ts +38 -0
  4. package/lib/commands/adopt.js +105 -0
  5. package/lib/commands/init.d.ts +29 -0
  6. package/lib/commands/init.js +152 -0
  7. package/lib/commands/upgrade.d.ts +33 -0
  8. package/lib/commands/upgrade.js +280 -0
  9. package/lib/index.d.ts +2 -0
  10. package/lib/index.js +123 -0
  11. package/lib/install-shared.d.ts +72 -0
  12. package/lib/install-shared.js +256 -0
  13. package/lib/manifest.d.ts +53 -0
  14. package/lib/manifest.js +74 -0
  15. package/lib/substitute.d.ts +15 -0
  16. package/lib/substitute.js +25 -0
  17. package/package.json +32 -0
  18. package/templates/functions/_shared/client.ts +20 -0
  19. package/templates/functions/_shared/config.ts +23 -0
  20. package/templates/functions/_shared/moderation.ts +102 -0
  21. package/templates/functions/_shared/push.ts +33 -0
  22. package/templates/functions/_shared/slack.ts +12 -0
  23. package/templates/functions/broadcast-post/index.ts +73 -0
  24. package/templates/functions/daily-moderation/index.ts +175 -0
  25. package/templates/functions/moderate-one/index.ts +62 -0
  26. package/templates/functions/notify-comment/index.ts +63 -0
  27. package/templates/functions/notify-like/index.ts +103 -0
  28. package/templates/functions/notify-reaction/index.ts +186 -0
  29. package/templates/functions/report-to-slack/index.ts +37 -0
  30. package/templates/functions/update-profile/index.ts +131 -0
  31. package/templates/migrations/core/001_tables.sql +177 -0
  32. package/templates/migrations/core/002_rls.sql +154 -0
  33. package/templates/migrations/core/003_moderation.sql +68 -0
  34. package/templates/migrations/core/004_dashboard.sql +102 -0
  35. package/templates/migrations/core/005_avatars.sql +19 -0
  36. package/templates/migrations/core/006_meta.sql +11 -0
  37. package/templates/migrations/inbox/001_inbox.sql +237 -0
  38. package/templates/migrations/polls/001_polls.sql +120 -0
  39. package/templates/migrations/push/001_push.sql +42 -0
  40. package/templates/migrations/push/002_triggers.sql +89 -0
  41. package/templates/migrations/reaction/001_reactions.sql +139 -0
@@ -0,0 +1,256 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.MODULE_ORDER = void 0;
37
+ exports.functionsForModules = functionsForModules;
38
+ exports.resolveModules = resolveModules;
39
+ exports.templateBaseName = templateBaseName;
40
+ exports.migrationDestFilename = migrationDestFilename;
41
+ exports.parseMigrationDestFilename = parseMigrationDestFilename;
42
+ exports.defaultTemplatesDir = defaultTemplatesDir;
43
+ exports.resolveTemplateRoots = resolveTemplateRoots;
44
+ exports.readOwnPackageVersion = readOwnPackageVersion;
45
+ exports.deriveProjectUrlFromConfig = deriveProjectUrlFromConfig;
46
+ exports.resolveProjectCredentials = resolveProjectCredentials;
47
+ exports.formatTimestamp = formatTimestamp;
48
+ exports.addSeconds = addSeconds;
49
+ exports.listFilesRecursive = listFilesRecursive;
50
+ exports.toRelativePosix = toRelativePosix;
51
+ exports.resolveContainedDir = resolveContainedDir;
52
+ const fs = __importStar(require("node:fs"));
53
+ const path = __importStar(require("node:path"));
54
+ const promises_1 = require("node:readline/promises");
55
+ /**
56
+ * Ruled install order (controller ruling, task 18): core -> push -> polls ->
57
+ * reaction -> inbox. Reaction MUST land before inbox — inbox/001's reaction
58
+ * trigger is guarded with `to_regclass('public.post_reactions')` at install
59
+ * time and is never retroactively wired if the reaction module shows up
60
+ * later, so `reaction` needs to already exist when `inbox` runs.
61
+ *
62
+ * Shared between `init` (task 18) and `upgrade`/`adopt` (task 19) — the
63
+ * single source of truth for module names, order, and the function sets
64
+ * each module pulls in.
65
+ */
66
+ exports.MODULE_ORDER = ["core", "push", "polls", "reaction", "inbox"];
67
+ const ALWAYS_FUNCTIONS = [
68
+ "moderate-one",
69
+ "daily-moderation",
70
+ "update-profile",
71
+ "report-to-slack",
72
+ "_shared",
73
+ ];
74
+ const PUSH_FUNCTIONS = ["notify-like", "notify-comment", "broadcast-post"];
75
+ const REACTION_FUNCTIONS = ["notify-reaction"];
76
+ function functionsForModules(modules) {
77
+ const set = new Set(ALWAYS_FUNCTIONS);
78
+ if (modules.includes("push"))
79
+ for (const f of PUSH_FUNCTIONS)
80
+ set.add(f);
81
+ if (modules.includes("reaction"))
82
+ for (const f of REACTION_FUNCTIONS)
83
+ set.add(f);
84
+ return [...set];
85
+ }
86
+ /**
87
+ * Validates and canonicalizes a requested module list: rejects unknown
88
+ * names, always implies `core`, orders the result per MODULE_ORDER, and
89
+ * warns (non-fatally) when `inbox` is picked without `reaction`.
90
+ */
91
+ function resolveModules(requested, onWarn) {
92
+ const source = requested && requested.length > 0 ? requested : [...exports.MODULE_ORDER];
93
+ const requestedSet = new Set(source.map((m) => m.trim()).filter(Boolean));
94
+ for (const m of requestedSet) {
95
+ if (!exports.MODULE_ORDER.includes(m)) {
96
+ throw new Error(`community-sdk: unknown module "${m}" (expected one of ${exports.MODULE_ORDER.join(", ")})`);
97
+ }
98
+ }
99
+ // core is always required/implied.
100
+ requestedSet.add("core");
101
+ const canonicalModules = exports.MODULE_ORDER.filter((m) => requestedSet.has(m));
102
+ if (canonicalModules.includes("inbox") && !canonicalModules.includes("reaction")) {
103
+ onWarn("community-sdk: module 'inbox' selected without 'reaction' — reaction notifications won't appear in the inbox until the reaction module is also installed.");
104
+ }
105
+ return canonicalModules;
106
+ }
107
+ /**
108
+ * Strips a template migration's leading numeric ordering prefix and `.sql`
109
+ * extension, e.g. "001_tables.sql" -> "tables". This is both the middle
110
+ * segment of the timestamped destination filename init/upgrade write
111
+ * (`<ts>_community_<module>_<name>.sql`) AND — paired with the module name —
112
+ * the stable identity `upgrade` diffs against (see manifest.ts
113
+ * `migrationTemplateId`).
114
+ */
115
+ function templateBaseName(templateFilename) {
116
+ return templateFilename.replace(/\.sql$/, "").replace(/^\d+_/, "");
117
+ }
118
+ function migrationDestFilename(timestamp, moduleName, name) {
119
+ return `${timestamp}_community_${moduleName}_${name}.sql`;
120
+ }
121
+ const MIGRATION_DEST_RE = /^\d{14}_community_([a-z]+)_(.+)\.sql$/;
122
+ /**
123
+ * Parses a filename previously written by init/upgrade
124
+ * (`<ts>_community_<module>_<name>.sql`) back into its module + base name.
125
+ * Returns null for anything else (e.g. a function file, or a migration a
126
+ * user hand-wrote outside the SDK) — used by `upgrade` as a fallback when
127
+ * reconstructing template identity from a manifest that predates the
128
+ * `installedTemplates` field.
129
+ */
130
+ function parseMigrationDestFilename(filename) {
131
+ const match = MIGRATION_DEST_RE.exec(filename);
132
+ if (!match)
133
+ return null;
134
+ return { moduleName: match[1], name: match[2] };
135
+ }
136
+ function defaultTemplatesDir() {
137
+ // This file and src/commands/*.ts both sit two levels below the package
138
+ // root (packages/cli), so this resolves identically pre- and post-build.
139
+ const packageRoot = path.resolve(__dirname, "..");
140
+ const packaged = path.join(packageRoot, "templates");
141
+ // STALENESS TRAP: once packages/cli/templates/ exists on disk (written by
142
+ // any prior `npm run build`, whose `prebuild` hook runs
143
+ // scripts/copy-templates.mjs), it wins over the live repo-root supabase/
144
+ // tree below — even during local dev. Editing supabase/**/*.sql or
145
+ // supabase/functions/** and then running `node lib/index.js init` (or a
146
+ // test that doesn't pass an explicit `templatesDir`) will silently use the
147
+ // last-built snapshot, not your edits. Rerun `npm run build` (or `node
148
+ // scripts/copy-templates.mjs` directly) to refresh packages/cli/templates/
149
+ // after touching supabase/.
150
+ if (fs.existsSync(packaged))
151
+ return packaged;
152
+ // Dev/test fallback: the monorepo's own supabase/ source of truth, used
153
+ // only while packages/cli/templates/ has never been built.
154
+ const repoRoot = path.resolve(packageRoot, "..", "..");
155
+ return path.join(repoRoot, "supabase");
156
+ }
157
+ /** Asserts `templatesDir` looks like a real template tree and returns its two roots. */
158
+ function resolveTemplateRoots(templatesDir) {
159
+ const migrationsSrcRoot = path.join(templatesDir, "migrations");
160
+ const functionsSrcRoot = path.join(templatesDir, "functions");
161
+ if (!fs.existsSync(migrationsSrcRoot) || !fs.existsSync(functionsSrcRoot)) {
162
+ throw new Error(`community-sdk: templates not found at ${templatesDir} (expected migrations/ and functions/ subdirectories)`);
163
+ }
164
+ return { migrationsSrcRoot, functionsSrcRoot };
165
+ }
166
+ function readOwnPackageVersion() {
167
+ const packageRoot = path.resolve(__dirname, "..");
168
+ const pkg = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf8"));
169
+ return pkg.version;
170
+ }
171
+ function deriveProjectUrlFromConfig(dir) {
172
+ const configPath = path.join(dir, "config.toml");
173
+ if (!fs.existsSync(configPath))
174
+ return null;
175
+ const raw = fs.readFileSync(configPath, "utf8");
176
+ const match = raw.match(/^\s*project_id\s*=\s*"([^"]+)"/m);
177
+ if (!match)
178
+ return null;
179
+ return `https://${match[1]}.supabase.co`;
180
+ }
181
+ function defaultPrompt(question) {
182
+ const rl = (0, promises_1.createInterface)({ input: process.stdin, output: process.stdout });
183
+ return rl.question(question).then((answer) => {
184
+ rl.close();
185
+ return answer;
186
+ });
187
+ }
188
+ /**
189
+ * Resolves the Supabase project URL + anon key from (in order) explicit
190
+ * flags, `<dir>/config.toml` (URL only), or an interactive prompt — failing
191
+ * loudly instead of hanging when stdin is not a TTY and no `prompt`
192
+ * override was injected. Shared by `init` and `upgrade`, both of which only
193
+ * need this when a placeholder-bearing file is about to be written.
194
+ */
195
+ async function resolveProjectCredentials(options) {
196
+ const usingDefaultPrompt = !options.prompt;
197
+ const prompt = options.prompt ?? defaultPrompt;
198
+ let projectUrl = options.projectUrl;
199
+ let anonKey = options.anonKey;
200
+ if (!projectUrl) {
201
+ projectUrl = deriveProjectUrlFromConfig(options.dir) ?? undefined;
202
+ }
203
+ const stillMissingFlags = [];
204
+ if (!projectUrl)
205
+ stillMissingFlags.push("--project-url");
206
+ if (!anonKey)
207
+ stillMissingFlags.push("--anon-key");
208
+ if (stillMissingFlags.length > 0 && usingDefaultPrompt && !process.stdin.isTTY) {
209
+ throw new Error(`community-sdk: missing required flag(s) ${stillMissingFlags.join(", ")}. stdin is not a TTY, so this command cannot prompt for ${stillMissingFlags.length > 1 ? "them" : "it"} — pass ${stillMissingFlags.length > 1 ? "them" : "it"} explicitly.`);
210
+ }
211
+ if (!projectUrl) {
212
+ projectUrl = (await prompt("Supabase project URL (https://<ref>.supabase.co): ")).trim();
213
+ }
214
+ if (!anonKey) {
215
+ anonKey = (await prompt("Supabase anon key: ")).trim();
216
+ }
217
+ return { projectUrl, anonKey };
218
+ }
219
+ function formatTimestamp(date) {
220
+ const pad = (n) => String(n).padStart(2, "0");
221
+ return (String(date.getUTCFullYear()) +
222
+ pad(date.getUTCMonth() + 1) +
223
+ pad(date.getUTCDate()) +
224
+ pad(date.getUTCHours()) +
225
+ pad(date.getUTCMinutes()) +
226
+ pad(date.getUTCSeconds()));
227
+ }
228
+ function addSeconds(date, seconds) {
229
+ return new Date(date.getTime() + seconds * 1000);
230
+ }
231
+ function listFilesRecursive(dir) {
232
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
233
+ const files = [];
234
+ for (const entry of entries) {
235
+ const full = path.join(dir, entry.name);
236
+ if (entry.isDirectory()) {
237
+ files.push(...listFilesRecursive(full));
238
+ }
239
+ else {
240
+ files.push(full);
241
+ }
242
+ }
243
+ return files;
244
+ }
245
+ function toRelativePosix(base, target) {
246
+ return path.relative(base, target).split(path.sep).join("/");
247
+ }
248
+ /** Resolves `dirRel` against `cwd`, throwing if it would escape the project root. */
249
+ function resolveContainedDir(cwd, dirRel) {
250
+ const cwdResolved = path.resolve(cwd);
251
+ const dir = path.resolve(cwdResolved, dirRel);
252
+ if (dir !== cwdResolved && !dir.startsWith(cwdResolved + path.sep)) {
253
+ throw new Error(`community-sdk: --dir must resolve to a directory inside the project root, got "${dirRel}" -> ${dir}`);
254
+ }
255
+ return dir;
256
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * community-sdk.json — written at the app repo root by `community init`
3
+ * (and updated by `community upgrade` / `community adopt`). Tracks what was
4
+ * installed so a second `init`/`adopt` can refuse to run and `upgrade`
5
+ * knows what it is diffing against.
6
+ */
7
+ export type Manifest = {
8
+ schemaVersion: number;
9
+ sdkVersion: string;
10
+ modules: string[];
11
+ installedFiles: string[];
12
+ /**
13
+ * Identity of every migration template already installed, one entry per
14
+ * template as `"<module>/<templateBaseName>"` (e.g. "core/tables",
15
+ * matching install-shared.ts `templateBaseName("001_tables.sql")`).
16
+ *
17
+ * This — not `installedFiles` — is what `upgrade` diffs the shipped
18
+ * template tree against, because installedFiles holds the *timestamped
19
+ * copy* name (`<ts>_community_core_tables.sql`) and a fresh timestamp is
20
+ * minted on every init/upgrade run, so those names can never be
21
+ * meaningfully compared across runs. The template's numeric ordering
22
+ * prefix (the "001" in "001_tables.sql") is intentionally dropped from
23
+ * the identity too: it only controls copy order within a module, and
24
+ * once a template has shipped, a real change to it must arrive as a new,
25
+ * separately-named template (migrations are append-only) rather than an
26
+ * edit — so base-name identity is exactly the granularity `upgrade`
27
+ * needs.
28
+ *
29
+ * Optional for backward compatibility: absent on manifests written by
30
+ * schemaVersion 1 `init` before this field existed (Task 18).
31
+ * `readManifest` still parses those manifests fine; `upgrade` falls back
32
+ * to reconstructing this set from `installedFiles`' timestamped filenames
33
+ * via `parseMigrationDestFilename` in that case (see install-shared.ts) —
34
+ * which recovers the exact same `<module>/<templateBaseName>` shape,
35
+ * since the prefix was already stripped before those filenames were
36
+ * written.
37
+ */
38
+ installedTemplates?: string[];
39
+ };
40
+ export declare const MANIFEST_FILENAME = "community-sdk.json";
41
+ /** The only schemaVersion this CLI currently writes. */
42
+ export declare const CURRENT_SCHEMA_VERSION = 1;
43
+ /**
44
+ * Stable identity for a migration template, used to populate/diff
45
+ * `Manifest.installedTemplates`. `templateBaseName` is the template's
46
+ * filename with its numeric prefix and `.sql` extension stripped (see
47
+ * install-shared.ts) — callers pass that in, not the raw template filename,
48
+ * so this function has no filesystem-naming knowledge of its own.
49
+ */
50
+ export declare function migrationTemplateId(moduleName: string, templateBaseName: string): string;
51
+ export declare function manifestPath(dir: string): string;
52
+ export declare function readManifest(dir: string): Manifest | null;
53
+ export declare function writeManifest(dir: string, manifest: Manifest): void;
@@ -0,0 +1,74 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.CURRENT_SCHEMA_VERSION = exports.MANIFEST_FILENAME = void 0;
37
+ exports.migrationTemplateId = migrationTemplateId;
38
+ exports.manifestPath = manifestPath;
39
+ exports.readManifest = readManifest;
40
+ exports.writeManifest = writeManifest;
41
+ const fs = __importStar(require("node:fs"));
42
+ const path = __importStar(require("node:path"));
43
+ exports.MANIFEST_FILENAME = "community-sdk.json";
44
+ /** The only schemaVersion this CLI currently writes. */
45
+ exports.CURRENT_SCHEMA_VERSION = 1;
46
+ /**
47
+ * Stable identity for a migration template, used to populate/diff
48
+ * `Manifest.installedTemplates`. `templateBaseName` is the template's
49
+ * filename with its numeric prefix and `.sql` extension stripped (see
50
+ * install-shared.ts) — callers pass that in, not the raw template filename,
51
+ * so this function has no filesystem-naming knowledge of its own.
52
+ */
53
+ function migrationTemplateId(moduleName, templateBaseName) {
54
+ return `${moduleName}/${templateBaseName}`;
55
+ }
56
+ function manifestPath(dir) {
57
+ return path.join(dir, exports.MANIFEST_FILENAME);
58
+ }
59
+ function readManifest(dir) {
60
+ const file = manifestPath(dir);
61
+ if (!fs.existsSync(file))
62
+ return null;
63
+ const raw = fs.readFileSync(file, "utf8");
64
+ try {
65
+ return JSON.parse(raw);
66
+ }
67
+ catch {
68
+ throw new Error(`community-sdk: ${file} is corrupt (invalid JSON)`);
69
+ }
70
+ }
71
+ function writeManifest(dir, manifest) {
72
+ fs.mkdirSync(dir, { recursive: true });
73
+ fs.writeFileSync(manifestPath(dir), `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
74
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Replaces the `__SUPABASE_PROJECT_URL__` / `__SUPABASE_ANON_KEY__` placeholders
3
+ * baked into the shipped migration SQL (see core/003, push/002, reaction/001)
4
+ * with the values for the app's own Supabase project.
5
+ *
6
+ * Throws if any `__SUPABASE...` token remains after substitution — a guard
7
+ * against a third, unhandled placeholder silently reaching the app's
8
+ * migrations directory (the migration's own DO block re-checks this at
9
+ * `db push` time, but failing fast here gives a clearer error before any
10
+ * SQL is written to disk).
11
+ */
12
+ export declare function substitutePlaceholders(sql: string, values: {
13
+ projectUrl: string;
14
+ anonKey: string;
15
+ }): string;
@@ -0,0 +1,25 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.substitutePlaceholders = substitutePlaceholders;
4
+ /**
5
+ * Replaces the `__SUPABASE_PROJECT_URL__` / `__SUPABASE_ANON_KEY__` placeholders
6
+ * baked into the shipped migration SQL (see core/003, push/002, reaction/001)
7
+ * with the values for the app's own Supabase project.
8
+ *
9
+ * Throws if any `__SUPABASE...` token remains after substitution — a guard
10
+ * against a third, unhandled placeholder silently reaching the app's
11
+ * migrations directory (the migration's own DO block re-checks this at
12
+ * `db push` time, but failing fast here gives a clearer error before any
13
+ * SQL is written to disk).
14
+ */
15
+ function substitutePlaceholders(sql, values) {
16
+ const result = sql
17
+ .split("__SUPABASE_PROJECT_URL__")
18
+ .join(values.projectUrl)
19
+ .split("__SUPABASE_ANON_KEY__")
20
+ .join(values.anonKey);
21
+ if (result.includes("__SUPABASE")) {
22
+ throw new Error("community-sdk: unsubstituted __SUPABASE placeholder remains after substitution");
23
+ }
24
+ return result;
25
+ }
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@rocapine/community",
3
+ "version": "0.1.0",
4
+ "description": "CLI to install, upgrade and adopt the Rocapine Community SDK's Supabase backend (migrations, Edge Functions) into a project.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/Rocapine/community-sdk"
9
+ },
10
+ "main": "lib/index.js",
11
+ "types": "lib/index.d.ts",
12
+ "files": [
13
+ "lib",
14
+ "templates",
15
+ "LICENSE"
16
+ ],
17
+ "bin": {
18
+ "community": "lib/index.js"
19
+ },
20
+ "scripts": {
21
+ "typecheck": "tsc --noEmit",
22
+ "prebuild": "node scripts/copy-templates.mjs",
23
+ "build": "tsc -p tsconfig.json --outDir lib --noEmit false",
24
+ "test": "vitest run"
25
+ },
26
+ "dependencies": {
27
+ "commander": "^12.0.0"
28
+ },
29
+ "devDependencies": {
30
+ "@types/node": "^22.10.0"
31
+ }
32
+ }
@@ -0,0 +1,20 @@
1
+ // Shared Supabase admin client + JSON response helper.
2
+
3
+ import { createClient } from "jsr:@supabase/supabase-js@2";
4
+
5
+ export const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
6
+
7
+ // Service-role client: bypasses RLS. Every function does privileged work
8
+ // through this; callers only ever pass the public anon JWT (verify_jwt).
9
+ export function adminClient() {
10
+ return createClient(SUPABASE_URL, Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!);
11
+ }
12
+
13
+ // The client consumes responses via supabase.functions.invoke, which only
14
+ // parses the body as JSON when the Content-Type says so. Without this header
15
+ // invoke returns a raw string and verdict.status is undefined.
16
+ export const json = (body: unknown, status = 200) =>
17
+ new Response(JSON.stringify(body), {
18
+ status,
19
+ headers: { "Content-Type": "application/json" },
20
+ });
@@ -0,0 +1,23 @@
1
+ // Per-app copy used inside notifications, injected as function secrets so the
2
+ // same function code deploys unchanged to every app:
3
+ //
4
+ // supabase secrets set COMMUNITY_FALLBACK_NAME="A member"
5
+ // supabase secrets set COMMUNITY_APP_NAME="My App"
6
+ // supabase secrets set COMMUNITY_REACTION_PUSH_TEXT="{name} sent you support"
7
+ //
8
+ // Defaults are deliberately neutral.
9
+
10
+ // Display name used when the acting user has no username yet
11
+ // ("<name>: ..." in comment pushes, "<name> liked your post").
12
+ export const FALLBACK_ACTOR_NAME = Deno.env.get("COMMUNITY_FALLBACK_NAME") ?? "Someone";
13
+
14
+ // Push title fallback for official broadcast posts whose author has no
15
+ // username (normally the official account's display name).
16
+ export const BROADCAST_FALLBACK_TITLE = Deno.env.get("COMMUNITY_APP_NAME") ?? "Community";
17
+
18
+ // notify-reaction's single-reactor push title template. `{name}` is
19
+ // substituted with the reactor's display name (or FALLBACK_ACTOR_NAME).
20
+ // Neutral default; an app with its own reaction semantics overrides this
21
+ // secret rather than the function code.
22
+ export const REACTION_PUSH_TEXT =
23
+ Deno.env.get("COMMUNITY_REACTION_PUSH_TEXT") ?? "{name} is thinking of you";
@@ -0,0 +1,102 @@
1
+ // Shared moderation policy: OpenAI omni-moderation categories, score
2
+ // thresholds and the API call. Single source of truth for moderate-one,
3
+ // daily-moderation and update-profile.
4
+
5
+ // Categories that always hide a piece of content when OpenAI's own boolean
6
+ // flag is true, regardless of MODERATION_EXCLUDED_CATEGORIES below.
7
+ export const CATEGORIES_TO_HIDE = [
8
+ "hate",
9
+ "hate/threatening",
10
+ "harassment",
11
+ "harassment/threatening",
12
+ "sexual",
13
+ "sexual/minors",
14
+ "violence",
15
+ "violence/graphic",
16
+ "self-harm",
17
+ "self-harm/intent",
18
+ "self-harm/instructions",
19
+ ];
20
+
21
+ // Comma-separated category list (e.g. "sexual,sexual/minors") an app can set
22
+ // to skip the supplementary raw-score check below for those categories —
23
+ // e.g. a wellness app allowing legitimate intimacy discussion sets
24
+ // MODERATION_EXCLUDED_CATEGORIES=sexual. The boolean check above still
25
+ // applies to every category regardless: this only silences the extra
26
+ // under-scored-content catch for the excluded ones. Default empty (no
27
+ // exclusion) — a neutral SDK default.
28
+ const EXCLUDED_CATEGORIES = new Set(
29
+ (Deno.env.get("MODERATION_EXCLUDED_CATEGORIES") ?? "")
30
+ .split(",")
31
+ .map((c) => c.trim())
32
+ .filter(Boolean),
33
+ );
34
+
35
+ // Harm categories also hidden on a raw SCORE >= threshold, not just OpenAI's
36
+ // conservative boolean, to catch under-scored insults/harassment (incl. other
37
+ // languages). Starts from CATEGORIES_TO_HIDE, minus MODERATION_EXCLUDED_CATEGORIES.
38
+ export const SCORE_HIDE = CATEGORIES_TO_HIDE.filter((c) => !EXCLUDED_CATEGORIES.has(c));
39
+
40
+ const parsedThreshold = Number(Deno.env.get("MODERATION_SCORE_THRESHOLD"));
41
+ // Number("") is 0, and Number.isFinite(0) is true — without the > 0 guard an
42
+ // unset/blank env var silently sets the threshold to 0, which hides every
43
+ // piece of content instead of falling back to the 0.5 default.
44
+ export const SCORE_THRESHOLD =
45
+ Number.isFinite(parsedThreshold) && parsedThreshold > 0 ? parsedThreshold : 0.5;
46
+
47
+ export interface ModerationResult {
48
+ categories: Record<string, boolean>;
49
+ category_scores: Record<string, number>;
50
+ }
51
+
52
+ export function flaggedCategories(result: ModerationResult): string[] {
53
+ const cats = new Set<string>();
54
+ for (const [cat, v] of Object.entries(result.categories)) {
55
+ if (v && CATEGORIES_TO_HIDE.includes(cat)) cats.add(cat);
56
+ }
57
+ for (const [cat, score] of Object.entries(result.category_scores)) {
58
+ if (SCORE_HIDE.includes(cat) && score >= SCORE_THRESHOLD) cats.add(cat);
59
+ }
60
+ return [...cats];
61
+ }
62
+
63
+ // Call once at module top-level in every function that moderates content
64
+ // (moderate-one, daily-moderation, update-profile) so a missing secret fails
65
+ // loudly at boot instead of degrading silently into "fail closed" forever
66
+ // (moderateInput would otherwise just log a 401 from OpenAI on every call).
67
+ export function assertModerationConfigured(): void {
68
+ if (!Deno.env.get("OPENAI_API_KEY")) {
69
+ throw new Error(
70
+ "community-sdk: OPENAI_API_KEY is not set. Moderation cannot run — set it with " +
71
+ "`supabase secrets set OPENAI_API_KEY=sk-...`.",
72
+ );
73
+ }
74
+ }
75
+
76
+ export type ImageInput = { type: "image_url"; image_url: { url: string } }[];
77
+
78
+ // POSTs to OpenAI moderation. `input` is a string for text or the image_url
79
+ // content-part array for an avatar. Returns null on a non-OK response so the
80
+ // caller can fail closed (nothing published / nothing written).
81
+ export async function moderateInput(
82
+ input: string | ImageInput,
83
+ ): Promise<{ flagged: boolean; reason: string | null } | null> {
84
+ const res = await fetch("https://api.openai.com/v1/moderations", {
85
+ method: "POST",
86
+ headers: {
87
+ Authorization: `Bearer ${Deno.env.get("OPENAI_API_KEY")}`,
88
+ "Content-Type": "application/json",
89
+ },
90
+ body: JSON.stringify({ model: "omni-moderation-latest", input }),
91
+ });
92
+ if (!res.ok) {
93
+ // Logged for ops (e.g. image_parse_error on a corrupt upload).
94
+ console.error("moderation api error", res.status, (await res.text()).slice(0, 300));
95
+ return null;
96
+ }
97
+ const { results } = await res.json();
98
+ const cats = flaggedCategories(results[0]);
99
+ return cats.length > 0
100
+ ? { flagged: true, reason: cats.join(",") }
101
+ : { flagged: false, reason: null };
102
+ }
@@ -0,0 +1,33 @@
1
+ // Shared Expo push sender. Used by notify-comment, notify-like,
2
+ // notify-reaction and broadcast-post (push module).
3
+
4
+ export interface ExpoPushMessage {
5
+ to: string;
6
+ title: string;
7
+ body?: string;
8
+ data: Record<string, string>;
9
+ // badge: 1 is a "something new" dot, not a real unread count — the app
10
+ // clears it on every foreground.
11
+ badge?: number;
12
+ }
13
+
14
+ // Sends in chunks of 100 (Expo API limit). Best-effort: push failures must
15
+ // never fail the calling webhook. Projects with Enhanced Security for push
16
+ // must set the EXPO_ACCESS_TOKEN function secret.
17
+ export async function sendExpoPushBatch(messages: ExpoPushMessage[]): Promise<void> {
18
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
19
+ const accessToken = Deno.env.get("EXPO_ACCESS_TOKEN");
20
+ if (accessToken) headers["Authorization"] = `Bearer ${accessToken}`;
21
+ for (let i = 0; i < messages.length; i += 100) {
22
+ const chunk = messages.slice(i, i + 100);
23
+ await fetch("https://exp.host/--/api/v2/push/send", {
24
+ method: "POST",
25
+ headers,
26
+ body: JSON.stringify(chunk.length === 1 ? chunk[0] : chunk),
27
+ }).catch(() => {});
28
+ }
29
+ }
30
+
31
+ export async function sendExpoPush(message: ExpoPushMessage): Promise<void> {
32
+ await sendExpoPushBatch([message]);
33
+ }
@@ -0,0 +1,12 @@
1
+ export async function postToSlack(payload: unknown): Promise<void> {
2
+ const url = Deno.env.get("SLACK_WEBHOOK_URL");
3
+ if (!url) {
4
+ console.log("community-sdk: SLACK_WEBHOOK_URL not set, skipping Slack notification");
5
+ return;
6
+ }
7
+ await fetch(url, {
8
+ method: "POST",
9
+ headers: { "content-type": "application/json" },
10
+ body: JSON.stringify(payload),
11
+ });
12
+ }