@clubays/clubgate-sdk 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +85 -8
- package/bin/emulator.js +50 -50
- package/bin/pack.js +86 -86
- package/dist/index.cjs +26 -8
- package/dist/index.d.cts +63 -11
- package/dist/index.d.ts +63 -11
- package/dist/index.js +24 -8
- package/emulator/host.js +84 -8
- package/emulator/mock-data.json +3 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @clubays/clubgate-sdk
|
|
2
2
|
|
|
3
|
-
SDK for building Clubays
|
|
3
|
+
SDK for building Clubgate applications that run inside Clubays.
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
@@ -11,15 +11,82 @@ pnpm add @clubays/clubgate-sdk
|
|
|
11
11
|
## Use
|
|
12
12
|
|
|
13
13
|
```js
|
|
14
|
-
import {
|
|
14
|
+
import { clubgate, init } from "@clubays/clubgate-sdk";
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
// optional; defaults to https://www.clubays.com
|
|
17
|
+
init({ hostOrigin: import.meta.env.VITE_HOST_ORIGIN });
|
|
17
18
|
|
|
18
|
-
const user = await
|
|
19
|
-
|
|
19
|
+
const user = await clubgate.user.get();
|
|
20
|
+
// { linkId, name, username, profileimage }
|
|
21
|
+
|
|
22
|
+
const profile = await clubgate.profile.get();
|
|
23
|
+
// { name, username, profileimage, coverimage, bio, description, links }
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Each method needs a scope that your app requested in `clubgate.json` and a reviewer approved:
|
|
27
|
+
|
|
28
|
+
| Method | Scope |
|
|
29
|
+
| ------------------------- | -------------- |
|
|
30
|
+
| `clubgate.user.get()` | `user.read` |
|
|
31
|
+
| `clubgate.profile.get()` | `profile.read` |
|
|
32
|
+
| `clubgate.link.status()` | none |
|
|
33
|
+
| `clubgate.link.request()` | none |
|
|
34
|
+
|
|
35
|
+
## Identity: use `linkId`, not `username`
|
|
36
|
+
|
|
37
|
+
`linkId` is a stable, per-app identifier for the user. Key your data on it. Usernames can change, and an old name can later belong to someone else, so use `username` for display only. Re-read `user.get()` each session instead of caching names.
|
|
38
|
+
|
|
39
|
+
The internal profile id is never exposed to apps.
|
|
40
|
+
|
|
41
|
+
## Sign-in and consent
|
|
42
|
+
|
|
43
|
+
Set `"access": "signin"` in `clubgate.json` to require users to connect your app. Clubays shows the consent screen itself, and your app cannot skip or fake it.
|
|
44
|
+
|
|
45
|
+
```js
|
|
46
|
+
const status = await clubgate.link.status();
|
|
47
|
+
// { linked, access, scopes }
|
|
48
|
+
|
|
49
|
+
if (!status.linked) {
|
|
50
|
+
try {
|
|
51
|
+
await clubgate.link.request(); // opens the consent screen, waits for the user
|
|
52
|
+
} catch (e) {
|
|
53
|
+
if (e.code === "consent_declined") {
|
|
54
|
+
// show a "connect to continue" screen
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
20
58
|
```
|
|
21
59
|
|
|
22
|
-
|
|
60
|
+
With `"access": "open"` (the default), the app works without linking, and `user.get()` returns `linkId: null` until the user connects.
|
|
61
|
+
|
|
62
|
+
## Errors
|
|
63
|
+
|
|
64
|
+
Errors are `ClubgateError` with a `code`:
|
|
65
|
+
|
|
66
|
+
| Code | Meaning |
|
|
67
|
+
| ------------------ | ----------------------------------------------------- |
|
|
68
|
+
| `scope_denied` | The scope was not requested, approved or consented to |
|
|
69
|
+
| `link_required` | The app needs the user to connect first |
|
|
70
|
+
| `link_revoked` | The user disconnected the app |
|
|
71
|
+
| `consent_declined` | The user declined the consent screen |
|
|
72
|
+
| `app_suspended` | The app was suspended |
|
|
73
|
+
| `invalid_params` | The request parameters were invalid |
|
|
74
|
+
| `rate_limited` | Too many requests, retry later |
|
|
75
|
+
| `unknown_method` | The method does not exist |
|
|
76
|
+
| `not_embedded` | The app was opened outside Clubays or the emulator |
|
|
77
|
+
| `timeout` | No response from the host |
|
|
78
|
+
|
|
79
|
+
```js
|
|
80
|
+
import { ClubgateError } from "@clubays/clubgate-sdk";
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
await clubgate.profile.get();
|
|
84
|
+
} catch (e) {
|
|
85
|
+
if (e instanceof ClubgateError && e.code === "scope_denied") {
|
|
86
|
+
/* ... */
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
```
|
|
23
90
|
|
|
24
91
|
## Local development
|
|
25
92
|
|
|
@@ -27,7 +94,13 @@ Errors are `ClubaysError` with a `code`: `scope_denied`, `not_embedded`, `timeou
|
|
|
27
94
|
pnpm clubgate-emulator --app http://localhost:5174
|
|
28
95
|
```
|
|
29
96
|
|
|
30
|
-
|
|
97
|
+
Open the URL it prints, and set `VITE_HOST_ORIGIN=http://localhost:5199` while using the emulator.
|
|
98
|
+
|
|
99
|
+
Edit `emulator/mock-data.json` to test different situations:
|
|
100
|
+
|
|
101
|
+
- `"access": "signin"` shows the consent screen on load
|
|
102
|
+
- remove a scope from `scopes` to test `scope_denied`
|
|
103
|
+
- `"linked": true` starts the user as already connected
|
|
31
104
|
|
|
32
105
|
## Package for upload
|
|
33
106
|
|
|
@@ -36,4 +109,8 @@ pnpm build
|
|
|
36
109
|
pnpm clubgate-pack
|
|
37
110
|
```
|
|
38
111
|
|
|
39
|
-
Creates `<id>-<version>.zip` from `dist/` and `
|
|
112
|
+
Creates `<id>-<version>.zip` from `dist/` and `clubgate.json`. Upload the zip through the developer portal.
|
|
113
|
+
|
|
114
|
+
## Notes
|
|
115
|
+
|
|
116
|
+
- `clubays` and `ClubaysError` still exist as deprecated aliases of `clubgate` and `ClubgateError`. They will be removed in 1.0.
|
package/bin/emulator.js
CHANGED
|
@@ -1,50 +1,50 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import http from "node:http";
|
|
3
|
-
import fs from "node:fs";
|
|
4
|
-
import path from "node:path";
|
|
5
|
-
import { fileURLToPath } from "node:url";
|
|
6
|
-
|
|
7
|
-
const root = path.join(
|
|
8
|
-
path.dirname(fileURLToPath(import.meta.url)),
|
|
9
|
-
"..",
|
|
10
|
-
"emulator",
|
|
11
|
-
);
|
|
12
|
-
const args = process.argv.slice(2);
|
|
13
|
-
const get = (flag, dflt) => {
|
|
14
|
-
const i = args.indexOf(flag);
|
|
15
|
-
return i > -1 ? args[i + 1] : dflt;
|
|
16
|
-
};
|
|
17
|
-
const port = Number(get("--port", 5199));
|
|
18
|
-
const app = get("--app", "http://localhost:5174");
|
|
19
|
-
|
|
20
|
-
const types = {
|
|
21
|
-
".html": "text/html",
|
|
22
|
-
".js": "text/javascript",
|
|
23
|
-
".json": "application/json",
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
http
|
|
27
|
-
.createServer((req, res) => {
|
|
28
|
-
const name = new URL(req.url, "http://x").pathname;
|
|
29
|
-
const file = path.join(
|
|
30
|
-
root,
|
|
31
|
-
name === "/" ? "index.html" : path.normalize(name),
|
|
32
|
-
);
|
|
33
|
-
if (
|
|
34
|
-
!file.startsWith(root) ||
|
|
35
|
-
!fs.existsSync(file) ||
|
|
36
|
-
fs.statSync(file).isDirectory()
|
|
37
|
-
) {
|
|
38
|
-
res.writeHead(404).end("not found");
|
|
39
|
-
return;
|
|
40
|
-
}
|
|
41
|
-
res.writeHead(200, {
|
|
42
|
-
"content-type": types[path.extname(file)] || "application/octet-stream",
|
|
43
|
-
});
|
|
44
|
-
fs.createReadStream(file).pipe(res);
|
|
45
|
-
})
|
|
46
|
-
.listen(port, () => {
|
|
47
|
-
console.log(
|
|
48
|
-
`Clubgate emulator: http://localhost:${port}/?app=${encodeURIComponent(app)}`,
|
|
49
|
-
);
|
|
50
|
-
});
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import http from "node:http";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
const root = path.join(
|
|
8
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
9
|
+
"..",
|
|
10
|
+
"emulator",
|
|
11
|
+
);
|
|
12
|
+
const args = process.argv.slice(2);
|
|
13
|
+
const get = (flag, dflt) => {
|
|
14
|
+
const i = args.indexOf(flag);
|
|
15
|
+
return i > -1 ? args[i + 1] : dflt;
|
|
16
|
+
};
|
|
17
|
+
const port = Number(get("--port", 5199));
|
|
18
|
+
const app = get("--app", "http://localhost:5174");
|
|
19
|
+
|
|
20
|
+
const types = {
|
|
21
|
+
".html": "text/html",
|
|
22
|
+
".js": "text/javascript",
|
|
23
|
+
".json": "application/json",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
http
|
|
27
|
+
.createServer((req, res) => {
|
|
28
|
+
const name = new URL(req.url, "http://x").pathname;
|
|
29
|
+
const file = path.join(
|
|
30
|
+
root,
|
|
31
|
+
name === "/" ? "index.html" : path.normalize(name),
|
|
32
|
+
);
|
|
33
|
+
if (
|
|
34
|
+
!file.startsWith(root) ||
|
|
35
|
+
!fs.existsSync(file) ||
|
|
36
|
+
fs.statSync(file).isDirectory()
|
|
37
|
+
) {
|
|
38
|
+
res.writeHead(404).end("not found");
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
res.writeHead(200, {
|
|
42
|
+
"content-type": types[path.extname(file)] || "application/octet-stream",
|
|
43
|
+
});
|
|
44
|
+
fs.createReadStream(file).pipe(res);
|
|
45
|
+
})
|
|
46
|
+
.listen(port, () => {
|
|
47
|
+
console.log(
|
|
48
|
+
`Clubgate emulator: http://localhost:${port}/?app=${encodeURIComponent(app)}`,
|
|
49
|
+
);
|
|
50
|
+
});
|
package/bin/pack.js
CHANGED
|
@@ -1,86 +1,86 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import fs from "node:fs";
|
|
3
|
-
import path from "node:path";
|
|
4
|
-
import archiver from "archiver";
|
|
5
|
-
|
|
6
|
-
const MAX_ZIP_BYTES = 20 * 1024 * 1024; // 20 MB
|
|
7
|
-
const SCOPES = ["user.read", "profile.read"]; // keep in sync with the SDK's SCOPES
|
|
8
|
-
const ID_RE = /^[a-z0-9][a-z0-9-]{1,30}[a-z0-9]$/;
|
|
9
|
-
const VERSION_RE = /^\d+\.\d+\.\d+$/;
|
|
10
|
-
|
|
11
|
-
const cwd = process.cwd();
|
|
12
|
-
const fail = (msg) => {
|
|
13
|
-
console.error(`\n x ${msg}\n`);
|
|
14
|
-
process.exit(1);
|
|
15
|
-
};
|
|
16
|
-
|
|
17
|
-
// 1. build output must exist
|
|
18
|
-
const dist = path.join(cwd, "dist");
|
|
19
|
-
if (!fs.existsSync(path.join(dist, "index.html"))) {
|
|
20
|
-
fail("dist/index.html not found. Run your build first (pnpm build).");
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
// 2. manifest must exist and be valid
|
|
24
|
-
const manifestPath = path.join(cwd, "
|
|
25
|
-
if (!fs.existsSync(manifestPath))
|
|
26
|
-
fail("
|
|
27
|
-
|
|
28
|
-
let manifest;
|
|
29
|
-
try {
|
|
30
|
-
manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
31
|
-
} catch {
|
|
32
|
-
fail("
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
const errors = [];
|
|
36
|
-
if (manifest.schemaVersion !== 1) errors.push("schemaVersion must be 1");
|
|
37
|
-
if (!ID_RE.test(manifest.id || ""))
|
|
38
|
-
errors.push("id must be lowercase letters, numbers, hyphens (3-32 chars)");
|
|
39
|
-
if (!manifest.name || manifest.name.length > 50)
|
|
40
|
-
errors.push("name is required (max 50 chars)");
|
|
41
|
-
if (!VERSION_RE.test(manifest.version || ""))
|
|
42
|
-
errors.push("version must look like 0.1.0");
|
|
43
|
-
if (!Array.isArray(manifest.scopes)) {
|
|
44
|
-
errors.push("scopes must be an array");
|
|
45
|
-
} else {
|
|
46
|
-
for (const s of manifest.scopes) {
|
|
47
|
-
if (!SCOPES.includes(s)) errors.push(`unknown scope: ${s}`);
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
if (errors.length)
|
|
51
|
-
fail("
|
|
52
|
-
|
|
53
|
-
// 3. refuse files that must never ship (secrets, source maps are fine to drop)
|
|
54
|
-
const BLOCKED = [/^\.env/, /\.pem$/, /\.key$/, /^node_modules$/];
|
|
55
|
-
const walk = (dir) =>
|
|
56
|
-
fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
|
|
57
|
-
const full = path.join(dir, e.name);
|
|
58
|
-
if (BLOCKED.some((re) => re.test(e.name)))
|
|
59
|
-
fail(`dist contains a blocked file: ${path.relative(cwd, full)}`);
|
|
60
|
-
return e.isDirectory() ? walk(full) : [full];
|
|
61
|
-
});
|
|
62
|
-
walk(dist);
|
|
63
|
-
|
|
64
|
-
// 4. zip dist/ contents at the zip root, plus
|
|
65
|
-
const outName = `${manifest.id}-${manifest.version}.zip`;
|
|
66
|
-
const outPath = path.join(cwd, outName);
|
|
67
|
-
const output = fs.createWriteStream(outPath);
|
|
68
|
-
const archive = archiver("zip", { zlib: { level: 9 } });
|
|
69
|
-
|
|
70
|
-
output.on("close", () => {
|
|
71
|
-
const size = archive.pointer();
|
|
72
|
-
if (size > MAX_ZIP_BYTES) {
|
|
73
|
-
fs.unlinkSync(outPath);
|
|
74
|
-
fail(
|
|
75
|
-
`zip is ${(size / 1048576).toFixed(1)} MB, over the ${MAX_ZIP_BYTES / 1048576} MB limit.`,
|
|
76
|
-
);
|
|
77
|
-
}
|
|
78
|
-
console.log(`\n ok ${outName} (${(size / 1024).toFixed(1)} KB)`);
|
|
79
|
-
console.log(" Upload this file to Clubays.\n");
|
|
80
|
-
});
|
|
81
|
-
archive.on("error", (e) => fail(e.message));
|
|
82
|
-
|
|
83
|
-
archive.pipe(output);
|
|
84
|
-
archive.directory(dist, false);
|
|
85
|
-
archive.file(manifestPath, { name: "
|
|
86
|
-
archive.finalize();
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import archiver from "archiver";
|
|
5
|
+
|
|
6
|
+
const MAX_ZIP_BYTES = 20 * 1024 * 1024; // 20 MB
|
|
7
|
+
const SCOPES = ["user.read", "profile.read"]; // keep in sync with the SDK's SCOPES
|
|
8
|
+
const ID_RE = /^[a-z0-9][a-z0-9-]{1,30}[a-z0-9]$/;
|
|
9
|
+
const VERSION_RE = /^\d+\.\d+\.\d+$/;
|
|
10
|
+
|
|
11
|
+
const cwd = process.cwd();
|
|
12
|
+
const fail = (msg) => {
|
|
13
|
+
console.error(`\n x ${msg}\n`);
|
|
14
|
+
process.exit(1);
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// 1. build output must exist
|
|
18
|
+
const dist = path.join(cwd, "dist");
|
|
19
|
+
if (!fs.existsSync(path.join(dist, "index.html"))) {
|
|
20
|
+
fail("dist/index.html not found. Run your build first (pnpm build).");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// 2. manifest must exist and be valid
|
|
24
|
+
const manifestPath = path.join(cwd, "clubgate.json");
|
|
25
|
+
if (!fs.existsSync(manifestPath))
|
|
26
|
+
fail("clubgate.json not found in the project root.");
|
|
27
|
+
|
|
28
|
+
let manifest;
|
|
29
|
+
try {
|
|
30
|
+
manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
31
|
+
} catch {
|
|
32
|
+
fail("clubgate.json is not valid JSON.");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const errors = [];
|
|
36
|
+
if (manifest.schemaVersion !== 1) errors.push("schemaVersion must be 1");
|
|
37
|
+
if (!ID_RE.test(manifest.id || ""))
|
|
38
|
+
errors.push("id must be lowercase letters, numbers, hyphens (3-32 chars)");
|
|
39
|
+
if (!manifest.name || manifest.name.length > 50)
|
|
40
|
+
errors.push("name is required (max 50 chars)");
|
|
41
|
+
if (!VERSION_RE.test(manifest.version || ""))
|
|
42
|
+
errors.push("version must look like 0.1.0");
|
|
43
|
+
if (!Array.isArray(manifest.scopes)) {
|
|
44
|
+
errors.push("scopes must be an array");
|
|
45
|
+
} else {
|
|
46
|
+
for (const s of manifest.scopes) {
|
|
47
|
+
if (!SCOPES.includes(s)) errors.push(`unknown scope: ${s}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (errors.length)
|
|
51
|
+
fail("clubgate.json problems:\n - " + errors.join("\n - "));
|
|
52
|
+
|
|
53
|
+
// 3. refuse files that must never ship (secrets, source maps are fine to drop)
|
|
54
|
+
const BLOCKED = [/^\.env/, /\.pem$/, /\.key$/, /^node_modules$/];
|
|
55
|
+
const walk = (dir) =>
|
|
56
|
+
fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
|
|
57
|
+
const full = path.join(dir, e.name);
|
|
58
|
+
if (BLOCKED.some((re) => re.test(e.name)))
|
|
59
|
+
fail(`dist contains a blocked file: ${path.relative(cwd, full)}`);
|
|
60
|
+
return e.isDirectory() ? walk(full) : [full];
|
|
61
|
+
});
|
|
62
|
+
walk(dist);
|
|
63
|
+
|
|
64
|
+
// 4. zip dist/ contents at the zip root, plus clubgate.json
|
|
65
|
+
const outName = `${manifest.id}-${manifest.version}.zip`;
|
|
66
|
+
const outPath = path.join(cwd, outName);
|
|
67
|
+
const output = fs.createWriteStream(outPath);
|
|
68
|
+
const archive = archiver("zip", { zlib: { level: 9 } });
|
|
69
|
+
|
|
70
|
+
output.on("close", () => {
|
|
71
|
+
const size = archive.pointer();
|
|
72
|
+
if (size > MAX_ZIP_BYTES) {
|
|
73
|
+
fs.unlinkSync(outPath);
|
|
74
|
+
fail(
|
|
75
|
+
`zip is ${(size / 1048576).toFixed(1)} MB, over the ${MAX_ZIP_BYTES / 1048576} MB limit.`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
console.log(`\n ok ${outName} (${(size / 1024).toFixed(1)} KB)`);
|
|
79
|
+
console.log(" Upload this file to Clubays.\n");
|
|
80
|
+
});
|
|
81
|
+
archive.on("error", (e) => fail(e.message));
|
|
82
|
+
|
|
83
|
+
archive.pipe(output);
|
|
84
|
+
archive.directory(dist, false);
|
|
85
|
+
archive.file(manifestPath, { name: "clubgate.json" });
|
|
86
|
+
archive.finalize();
|
package/dist/index.cjs
CHANGED
|
@@ -21,14 +21,16 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
21
21
|
var index_exports = {};
|
|
22
22
|
__export(index_exports, {
|
|
23
23
|
ClubaysError: () => ClubaysError,
|
|
24
|
+
ClubgateError: () => ClubgateError,
|
|
24
25
|
clubays: () => clubays,
|
|
26
|
+
clubgate: () => clubgate,
|
|
25
27
|
init: () => init
|
|
26
28
|
});
|
|
27
29
|
module.exports = __toCommonJS(index_exports);
|
|
28
|
-
var
|
|
30
|
+
var ClubgateError = class extends Error {
|
|
29
31
|
constructor(code) {
|
|
30
32
|
super(code);
|
|
31
|
-
this.name = "
|
|
33
|
+
this.name = "ClubgateError";
|
|
32
34
|
this.code = code;
|
|
33
35
|
}
|
|
34
36
|
};
|
|
@@ -62,11 +64,11 @@ function onMessage(e) {
|
|
|
62
64
|
if (!p) return;
|
|
63
65
|
pending.delete(id);
|
|
64
66
|
clearTimeout(p.timer);
|
|
65
|
-
error ? p.reject(new
|
|
67
|
+
error ? p.reject(new ClubgateError(error)) : p.resolve(result);
|
|
66
68
|
}
|
|
67
|
-
function request(method, params) {
|
|
69
|
+
function request(method, params, opts = {}) {
|
|
68
70
|
if (typeof window === "undefined" || window.parent === window) {
|
|
69
|
-
return Promise.reject(new
|
|
71
|
+
return Promise.reject(new ClubgateError("not_embedded"));
|
|
70
72
|
}
|
|
71
73
|
if (!listening) {
|
|
72
74
|
window.addEventListener("message", onMessage);
|
|
@@ -75,8 +77,8 @@ function request(method, params) {
|
|
|
75
77
|
return new Promise((resolve, reject) => {
|
|
76
78
|
const id = ++seq;
|
|
77
79
|
const timer = setTimeout(() => {
|
|
78
|
-
if (pending.delete(id)) reject(new
|
|
79
|
-
}, timeoutMs);
|
|
80
|
+
if (pending.delete(id)) reject(new ClubgateError("timeout"));
|
|
81
|
+
}, opts.timeoutMs ?? timeoutMs);
|
|
80
82
|
pending.set(id, { resolve, reject, timer });
|
|
81
83
|
window.parent.postMessage(
|
|
82
84
|
{ v: PROTOCOL_VERSION, id, method, params },
|
|
@@ -84,7 +86,19 @@ function request(method, params) {
|
|
|
84
86
|
);
|
|
85
87
|
});
|
|
86
88
|
}
|
|
87
|
-
var
|
|
89
|
+
var clubgate = {
|
|
90
|
+
link: {
|
|
91
|
+
/** Is this user linked to the app, and with which scopes? */
|
|
92
|
+
status: () => request("link.status"),
|
|
93
|
+
/**
|
|
94
|
+
* Ask the host to show the consent screen. Resolves when the user accepts,
|
|
95
|
+
* rejects with `consent_declined` if they decline. The app cannot skip it.
|
|
96
|
+
* Waits for the user, so it uses a long timeout.
|
|
97
|
+
*/
|
|
98
|
+
request: () => request("link.request", void 0, {
|
|
99
|
+
timeoutMs: 12e4
|
|
100
|
+
})
|
|
101
|
+
},
|
|
88
102
|
user: {
|
|
89
103
|
get: () => request("user.get")
|
|
90
104
|
},
|
|
@@ -92,9 +106,13 @@ var clubays = {
|
|
|
92
106
|
get: () => request("profile.get")
|
|
93
107
|
}
|
|
94
108
|
};
|
|
109
|
+
var clubays = clubgate;
|
|
110
|
+
var ClubaysError = ClubgateError;
|
|
95
111
|
// Annotate the CommonJS export names for ESM import in node:
|
|
96
112
|
0 && (module.exports = {
|
|
97
113
|
ClubaysError,
|
|
114
|
+
ClubgateError,
|
|
98
115
|
clubays,
|
|
116
|
+
clubgate,
|
|
99
117
|
init
|
|
100
118
|
});
|
package/dist/index.d.cts
CHANGED
|
@@ -1,19 +1,28 @@
|
|
|
1
|
-
interface
|
|
2
|
-
|
|
1
|
+
interface ClubgateUser {
|
|
2
|
+
/** Stable per-app identity. null when the app is open and the user hasn't linked. */
|
|
3
|
+
linkId: string | null;
|
|
3
4
|
name: string;
|
|
4
5
|
username: string;
|
|
5
6
|
profileimage: string | null;
|
|
6
7
|
}
|
|
7
|
-
interface
|
|
8
|
+
interface ClubgateProfile {
|
|
9
|
+
name: string;
|
|
10
|
+
username: string;
|
|
11
|
+
profileimage: string | null;
|
|
12
|
+
coverimage: string | null;
|
|
8
13
|
bio: string | null;
|
|
9
14
|
description: string | null;
|
|
10
|
-
coverimage: string | null;
|
|
11
15
|
links: string[] | null;
|
|
12
16
|
}
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
+
interface LinkStatus {
|
|
18
|
+
linked: boolean;
|
|
19
|
+
access: "open" | "signin";
|
|
20
|
+
scopes: string[];
|
|
21
|
+
}
|
|
22
|
+
type ClubgateErrorCode = "scope_denied" | "unknown_method" | "app_suspended" | "link_required" | "link_revoked" | "consent_declined" | "invalid_params" | "rate_limited" | "timeout" | "not_embedded" | "failed" | (string & {});
|
|
23
|
+
declare class ClubgateError extends Error {
|
|
24
|
+
code: ClubgateErrorCode;
|
|
25
|
+
constructor(code: ClubgateErrorCode);
|
|
17
26
|
}
|
|
18
27
|
interface InitOptions {
|
|
19
28
|
/** Origin of the Clubays host page. Defaults to production. */
|
|
@@ -22,13 +31,56 @@ interface InitOptions {
|
|
|
22
31
|
timeoutMs?: number;
|
|
23
32
|
}
|
|
24
33
|
declare function init(options?: InitOptions): void;
|
|
34
|
+
declare const clubgate: {
|
|
35
|
+
link: {
|
|
36
|
+
/** Is this user linked to the app, and with which scopes? */
|
|
37
|
+
status: () => Promise<LinkStatus>;
|
|
38
|
+
/**
|
|
39
|
+
* Ask the host to show the consent screen. Resolves when the user accepts,
|
|
40
|
+
* rejects with `consent_declined` if they decline. The app cannot skip it.
|
|
41
|
+
* Waits for the user, so it uses a long timeout.
|
|
42
|
+
*/
|
|
43
|
+
request: () => Promise<{
|
|
44
|
+
linked: boolean;
|
|
45
|
+
}>;
|
|
46
|
+
};
|
|
47
|
+
user: {
|
|
48
|
+
get: () => Promise<ClubgateUser>;
|
|
49
|
+
};
|
|
50
|
+
profile: {
|
|
51
|
+
get: () => Promise<ClubgateProfile>;
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
/** @deprecated use `clubgate` */
|
|
25
55
|
declare const clubays: {
|
|
56
|
+
link: {
|
|
57
|
+
/** Is this user linked to the app, and with which scopes? */
|
|
58
|
+
status: () => Promise<LinkStatus>;
|
|
59
|
+
/**
|
|
60
|
+
* Ask the host to show the consent screen. Resolves when the user accepts,
|
|
61
|
+
* rejects with `consent_declined` if they decline. The app cannot skip it.
|
|
62
|
+
* Waits for the user, so it uses a long timeout.
|
|
63
|
+
*/
|
|
64
|
+
request: () => Promise<{
|
|
65
|
+
linked: boolean;
|
|
66
|
+
}>;
|
|
67
|
+
};
|
|
26
68
|
user: {
|
|
27
|
-
get: () => Promise<
|
|
69
|
+
get: () => Promise<ClubgateUser>;
|
|
28
70
|
};
|
|
29
71
|
profile: {
|
|
30
|
-
get: () => Promise<
|
|
72
|
+
get: () => Promise<ClubgateProfile>;
|
|
31
73
|
};
|
|
32
74
|
};
|
|
75
|
+
/** @deprecated use `ClubgateError` */
|
|
76
|
+
declare const ClubaysError: typeof ClubgateError;
|
|
77
|
+
/** @deprecated use `ClubgateError` */
|
|
78
|
+
type ClubaysError = ClubgateError;
|
|
79
|
+
/** @deprecated use `ClubgateUser` */
|
|
80
|
+
type ClubaysUser = ClubgateUser;
|
|
81
|
+
/** @deprecated use `ClubgateProfile` */
|
|
82
|
+
type ClubaysProfile = ClubgateProfile;
|
|
83
|
+
/** @deprecated use `ClubgateErrorCode` */
|
|
84
|
+
type ClubaysErrorCode = ClubgateErrorCode;
|
|
33
85
|
|
|
34
|
-
export { ClubaysError, type ClubaysErrorCode, type ClubaysProfile, type ClubaysUser, type InitOptions, clubays, init };
|
|
86
|
+
export { ClubaysError, type ClubaysErrorCode, type ClubaysProfile, type ClubaysUser, ClubgateError, type ClubgateErrorCode, type ClubgateProfile, type ClubgateUser, type InitOptions, type LinkStatus, clubays, clubgate, init };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,19 +1,28 @@
|
|
|
1
|
-
interface
|
|
2
|
-
|
|
1
|
+
interface ClubgateUser {
|
|
2
|
+
/** Stable per-app identity. null when the app is open and the user hasn't linked. */
|
|
3
|
+
linkId: string | null;
|
|
3
4
|
name: string;
|
|
4
5
|
username: string;
|
|
5
6
|
profileimage: string | null;
|
|
6
7
|
}
|
|
7
|
-
interface
|
|
8
|
+
interface ClubgateProfile {
|
|
9
|
+
name: string;
|
|
10
|
+
username: string;
|
|
11
|
+
profileimage: string | null;
|
|
12
|
+
coverimage: string | null;
|
|
8
13
|
bio: string | null;
|
|
9
14
|
description: string | null;
|
|
10
|
-
coverimage: string | null;
|
|
11
15
|
links: string[] | null;
|
|
12
16
|
}
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
+
interface LinkStatus {
|
|
18
|
+
linked: boolean;
|
|
19
|
+
access: "open" | "signin";
|
|
20
|
+
scopes: string[];
|
|
21
|
+
}
|
|
22
|
+
type ClubgateErrorCode = "scope_denied" | "unknown_method" | "app_suspended" | "link_required" | "link_revoked" | "consent_declined" | "invalid_params" | "rate_limited" | "timeout" | "not_embedded" | "failed" | (string & {});
|
|
23
|
+
declare class ClubgateError extends Error {
|
|
24
|
+
code: ClubgateErrorCode;
|
|
25
|
+
constructor(code: ClubgateErrorCode);
|
|
17
26
|
}
|
|
18
27
|
interface InitOptions {
|
|
19
28
|
/** Origin of the Clubays host page. Defaults to production. */
|
|
@@ -22,13 +31,56 @@ interface InitOptions {
|
|
|
22
31
|
timeoutMs?: number;
|
|
23
32
|
}
|
|
24
33
|
declare function init(options?: InitOptions): void;
|
|
34
|
+
declare const clubgate: {
|
|
35
|
+
link: {
|
|
36
|
+
/** Is this user linked to the app, and with which scopes? */
|
|
37
|
+
status: () => Promise<LinkStatus>;
|
|
38
|
+
/**
|
|
39
|
+
* Ask the host to show the consent screen. Resolves when the user accepts,
|
|
40
|
+
* rejects with `consent_declined` if they decline. The app cannot skip it.
|
|
41
|
+
* Waits for the user, so it uses a long timeout.
|
|
42
|
+
*/
|
|
43
|
+
request: () => Promise<{
|
|
44
|
+
linked: boolean;
|
|
45
|
+
}>;
|
|
46
|
+
};
|
|
47
|
+
user: {
|
|
48
|
+
get: () => Promise<ClubgateUser>;
|
|
49
|
+
};
|
|
50
|
+
profile: {
|
|
51
|
+
get: () => Promise<ClubgateProfile>;
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
/** @deprecated use `clubgate` */
|
|
25
55
|
declare const clubays: {
|
|
56
|
+
link: {
|
|
57
|
+
/** Is this user linked to the app, and with which scopes? */
|
|
58
|
+
status: () => Promise<LinkStatus>;
|
|
59
|
+
/**
|
|
60
|
+
* Ask the host to show the consent screen. Resolves when the user accepts,
|
|
61
|
+
* rejects with `consent_declined` if they decline. The app cannot skip it.
|
|
62
|
+
* Waits for the user, so it uses a long timeout.
|
|
63
|
+
*/
|
|
64
|
+
request: () => Promise<{
|
|
65
|
+
linked: boolean;
|
|
66
|
+
}>;
|
|
67
|
+
};
|
|
26
68
|
user: {
|
|
27
|
-
get: () => Promise<
|
|
69
|
+
get: () => Promise<ClubgateUser>;
|
|
28
70
|
};
|
|
29
71
|
profile: {
|
|
30
|
-
get: () => Promise<
|
|
72
|
+
get: () => Promise<ClubgateProfile>;
|
|
31
73
|
};
|
|
32
74
|
};
|
|
75
|
+
/** @deprecated use `ClubgateError` */
|
|
76
|
+
declare const ClubaysError: typeof ClubgateError;
|
|
77
|
+
/** @deprecated use `ClubgateError` */
|
|
78
|
+
type ClubaysError = ClubgateError;
|
|
79
|
+
/** @deprecated use `ClubgateUser` */
|
|
80
|
+
type ClubaysUser = ClubgateUser;
|
|
81
|
+
/** @deprecated use `ClubgateProfile` */
|
|
82
|
+
type ClubaysProfile = ClubgateProfile;
|
|
83
|
+
/** @deprecated use `ClubgateErrorCode` */
|
|
84
|
+
type ClubaysErrorCode = ClubgateErrorCode;
|
|
33
85
|
|
|
34
|
-
export { ClubaysError, type ClubaysErrorCode, type ClubaysProfile, type ClubaysUser, type InitOptions, clubays, init };
|
|
86
|
+
export { ClubaysError, type ClubaysErrorCode, type ClubaysProfile, type ClubaysUser, ClubgateError, type ClubgateErrorCode, type ClubgateProfile, type ClubgateUser, type InitOptions, type LinkStatus, clubays, clubgate, init };
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
|
-
var
|
|
2
|
+
var ClubgateError = class extends Error {
|
|
3
3
|
constructor(code) {
|
|
4
4
|
super(code);
|
|
5
|
-
this.name = "
|
|
5
|
+
this.name = "ClubgateError";
|
|
6
6
|
this.code = code;
|
|
7
7
|
}
|
|
8
8
|
};
|
|
@@ -36,11 +36,11 @@ function onMessage(e) {
|
|
|
36
36
|
if (!p) return;
|
|
37
37
|
pending.delete(id);
|
|
38
38
|
clearTimeout(p.timer);
|
|
39
|
-
error ? p.reject(new
|
|
39
|
+
error ? p.reject(new ClubgateError(error)) : p.resolve(result);
|
|
40
40
|
}
|
|
41
|
-
function request(method, params) {
|
|
41
|
+
function request(method, params, opts = {}) {
|
|
42
42
|
if (typeof window === "undefined" || window.parent === window) {
|
|
43
|
-
return Promise.reject(new
|
|
43
|
+
return Promise.reject(new ClubgateError("not_embedded"));
|
|
44
44
|
}
|
|
45
45
|
if (!listening) {
|
|
46
46
|
window.addEventListener("message", onMessage);
|
|
@@ -49,8 +49,8 @@ function request(method, params) {
|
|
|
49
49
|
return new Promise((resolve, reject) => {
|
|
50
50
|
const id = ++seq;
|
|
51
51
|
const timer = setTimeout(() => {
|
|
52
|
-
if (pending.delete(id)) reject(new
|
|
53
|
-
}, timeoutMs);
|
|
52
|
+
if (pending.delete(id)) reject(new ClubgateError("timeout"));
|
|
53
|
+
}, opts.timeoutMs ?? timeoutMs);
|
|
54
54
|
pending.set(id, { resolve, reject, timer });
|
|
55
55
|
window.parent.postMessage(
|
|
56
56
|
{ v: PROTOCOL_VERSION, id, method, params },
|
|
@@ -58,7 +58,19 @@ function request(method, params) {
|
|
|
58
58
|
);
|
|
59
59
|
});
|
|
60
60
|
}
|
|
61
|
-
var
|
|
61
|
+
var clubgate = {
|
|
62
|
+
link: {
|
|
63
|
+
/** Is this user linked to the app, and with which scopes? */
|
|
64
|
+
status: () => request("link.status"),
|
|
65
|
+
/**
|
|
66
|
+
* Ask the host to show the consent screen. Resolves when the user accepts,
|
|
67
|
+
* rejects with `consent_declined` if they decline. The app cannot skip it.
|
|
68
|
+
* Waits for the user, so it uses a long timeout.
|
|
69
|
+
*/
|
|
70
|
+
request: () => request("link.request", void 0, {
|
|
71
|
+
timeoutMs: 12e4
|
|
72
|
+
})
|
|
73
|
+
},
|
|
62
74
|
user: {
|
|
63
75
|
get: () => request("user.get")
|
|
64
76
|
},
|
|
@@ -66,8 +78,12 @@ var clubays = {
|
|
|
66
78
|
get: () => request("profile.get")
|
|
67
79
|
}
|
|
68
80
|
};
|
|
81
|
+
var clubays = clubgate;
|
|
82
|
+
var ClubaysError = ClubgateError;
|
|
69
83
|
export {
|
|
70
84
|
ClubaysError,
|
|
85
|
+
ClubgateError,
|
|
71
86
|
clubays,
|
|
87
|
+
clubgate,
|
|
72
88
|
init
|
|
73
89
|
};
|
package/emulator/host.js
CHANGED
|
@@ -4,22 +4,98 @@ const appOrigin = new URL(appUrl).origin;
|
|
|
4
4
|
|
|
5
5
|
const data = await fetch("./mock-data.json").then((r) => r.json());
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
// state the real host keeps per user
|
|
8
|
+
let linked = data.linked === true;
|
|
9
|
+
|
|
10
|
+
const LABELS = {
|
|
11
|
+
"user.read": "See your name, username and photo",
|
|
12
|
+
"profile.read": "See your public profile",
|
|
10
13
|
};
|
|
11
14
|
|
|
12
15
|
const frame = document.getElementById("app");
|
|
13
16
|
frame.src = appUrl;
|
|
14
17
|
|
|
15
|
-
|
|
18
|
+
// ── consent dialog (the app can never skip or fake this) ──
|
|
19
|
+
const waiters = [];
|
|
20
|
+
const dialog = document.createElement("div");
|
|
21
|
+
dialog.style.cssText =
|
|
22
|
+
"display:none;position:fixed;inset:0;background:rgba(0,0,0,.4);align-items:center;justify-content:center;font-family:system-ui;z-index:10";
|
|
23
|
+
dialog.innerHTML = `
|
|
24
|
+
<div style="background:#fff;border-radius:16px;padding:20px;width:320px">
|
|
25
|
+
<p style="margin:0;font-size:14px;font-weight:600">Connect this app</p>
|
|
26
|
+
<p style="margin:6px 0 10px;font-size:12px;color:#64748b">This app will be able to:</p>
|
|
27
|
+
<ul id="scopes" style="margin:0;padding-left:18px;font-size:12px"></ul>
|
|
28
|
+
<div style="margin-top:16px;display:flex;justify-content:flex-end;gap:8px">
|
|
29
|
+
<button id="no" style="padding:6px 12px;font-size:12px">Not now</button>
|
|
30
|
+
<button id="yes" style="padding:6px 12px;font-size:12px;background:#7c3aed;color:#fff;border:0;border-radius:8px">Allow</button>
|
|
31
|
+
</div>
|
|
32
|
+
</div>`;
|
|
33
|
+
document.body.appendChild(dialog);
|
|
34
|
+
dialog.querySelector("#scopes").innerHTML = data.scopes
|
|
35
|
+
.map((s) => `<li>${LABELS[s] ?? s}</li>`)
|
|
36
|
+
.join("");
|
|
37
|
+
|
|
38
|
+
const showConsent = () =>
|
|
39
|
+
new Promise((resolve, reject) => {
|
|
40
|
+
waiters.push({ resolve, reject });
|
|
41
|
+
dialog.style.display = "flex";
|
|
42
|
+
});
|
|
43
|
+
const closeConsent = () => (dialog.style.display = "none");
|
|
44
|
+
|
|
45
|
+
dialog.querySelector("#yes").onclick = () => {
|
|
46
|
+
linked = true;
|
|
47
|
+
closeConsent();
|
|
48
|
+
waiters.splice(0).forEach((w) => w.resolve({ linked: true }));
|
|
49
|
+
};
|
|
50
|
+
dialog.querySelector("#no").onclick = () => {
|
|
51
|
+
closeConsent();
|
|
52
|
+
waiters.splice(0).forEach((w) => w.reject("consent_declined"));
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// signin apps show consent on load, like the real host
|
|
56
|
+
if (data.access === "signin" && !linked) showConsent().catch(() => {});
|
|
57
|
+
|
|
58
|
+
// ── methods ──
|
|
59
|
+
const METHODS = {
|
|
60
|
+
"link.status": {
|
|
61
|
+
scopes: [],
|
|
62
|
+
run: () => ({
|
|
63
|
+
linked,
|
|
64
|
+
access: data.access,
|
|
65
|
+
scopes: linked ? data.scopes : [],
|
|
66
|
+
}),
|
|
67
|
+
},
|
|
68
|
+
"user.get": {
|
|
69
|
+
scopes: ["user.read"],
|
|
70
|
+
run: () => ({ linkId: linked ? data.linkId : null, ...data.user }),
|
|
71
|
+
},
|
|
72
|
+
"profile.get": {
|
|
73
|
+
scopes: ["profile.read"],
|
|
74
|
+
run: () => data.profile,
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
window.addEventListener("message", async (e) => {
|
|
16
79
|
if (e.origin !== appOrigin || e.source !== frame.contentWindow) return;
|
|
17
80
|
const { id, method } = e.data || {};
|
|
81
|
+
if (typeof method !== "string") return;
|
|
18
82
|
const reply = (msg) => e.source.postMessage({ id, ...msg }, e.origin);
|
|
19
83
|
|
|
20
|
-
|
|
21
|
-
if (
|
|
22
|
-
|
|
84
|
+
// handled by the host only, like the real one
|
|
85
|
+
if (method === "link.request") {
|
|
86
|
+
if (linked) return reply({ result: { linked: true } });
|
|
87
|
+
try {
|
|
88
|
+
return reply({ result: await showConsent() });
|
|
89
|
+
} catch (code) {
|
|
90
|
+
return reply({ error: code });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const def = METHODS[method];
|
|
95
|
+
if (!def) return reply({ error: "unknown_method" });
|
|
96
|
+
if (data.access === "signin" && !linked && method !== "link.status")
|
|
97
|
+
return reply({ error: "link_required" });
|
|
98
|
+
if (!def.scopes.every((s) => data.scopes.includes(s)))
|
|
23
99
|
return reply({ error: "scope_denied" });
|
|
24
|
-
reply({ result:
|
|
100
|
+
reply({ result: def.run() });
|
|
25
101
|
});
|
package/emulator/mock-data.json
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
{
|
|
2
|
+
"access": "open",
|
|
3
|
+
"linked": false,
|
|
2
4
|
"scopes": ["user.read", "profile.read"],
|
|
5
|
+
"linkId": "11111111-1111-4111-8111-111111111111",
|
|
3
6
|
"user": {
|
|
4
|
-
"profile_id": "mock0001",
|
|
5
7
|
"name": "Test User",
|
|
6
8
|
"username": "test_user",
|
|
7
9
|
"profileimage": null
|
|
8
10
|
},
|
|
9
11
|
"profile": {
|
|
10
|
-
"profile_id": "mock0001",
|
|
11
12
|
"name": "Test User",
|
|
12
13
|
"username": "test_user",
|
|
13
14
|
"bio": "Mock bio",
|