@voltro/plugin-licensing 0.84.0 → 0.86.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/CHANGELOG.md +1215 -0
- package/THIRD-PARTY-NOTICES.md +118 -600
- package/dist/index.d.ts +7 -5
- package/dist/index.js +41 -114
- package/dist/refresh.js +57 -0
- package/dist/resolver.js +9 -0
- package/dist/snapshot.js +21 -0
- package/dist/verify.js +55 -0
- package/package.json +6 -6
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Effect } from 'effect';
|
|
2
|
-
import {
|
|
2
|
+
import { Service } from 'effect/Context';
|
|
3
3
|
import { VoidIfEmpty } from 'effect/Types';
|
|
4
4
|
import { VoltroPlugin } from '@voltro/protocol';
|
|
5
5
|
import { YieldableError } from 'effect/Cause';
|
|
@@ -36,7 +36,7 @@ export declare interface LicenseClaims {
|
|
|
36
36
|
readonly kid?: string;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
export declare const LicenseService:
|
|
39
|
+
export declare const LicenseService: Service<LicenseServiceShape, LicenseServiceShape>;
|
|
40
40
|
|
|
41
41
|
export declare interface LicenseServiceShape {
|
|
42
42
|
readonly snapshot: () => LicenseSnapshot | undefined;
|
|
@@ -119,10 +119,12 @@ export declare interface LicensingPluginOptions {
|
|
|
119
119
|
readonly publicKey?: string;
|
|
120
120
|
/** The signed license token. Falls back to `VOLTRO_LICENSE_KEY`. */
|
|
121
121
|
readonly licenseKey?: string;
|
|
122
|
-
/** URL
|
|
123
|
-
*
|
|
124
|
-
* the snapshot. (The cloud endpoint that serves it ships in a later milestone.) */
|
|
122
|
+
/** URL returning JSON containing a signed `token`. The coordinated refresh
|
|
123
|
+
* verifies its signature before installing the new snapshot. */
|
|
125
124
|
readonly snapshotUrl?: string;
|
|
125
|
+
/** Server-only snapshot headers, for example a service Authorization header.
|
|
126
|
+
* Redirects are refused. Keep credentials out of browsers and URLs. */
|
|
127
|
+
readonly snapshotHeaders?: Readonly<Record<string, string>>;
|
|
126
128
|
/** Refresh interval (ms) for `snapshotUrl`. Default 15 minutes. */
|
|
127
129
|
readonly refreshMs?: number;
|
|
128
130
|
/** Seconds an expired license is still honored (grace) before it goes dark.
|
package/dist/index.js
CHANGED
|
@@ -1,84 +1,18 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
}) : new c({
|
|
12
|
-
reason: "invalid",
|
|
13
|
-
message: t
|
|
14
|
-
});
|
|
15
|
-
}, d = (e) => e === "unlimited" || typeof e == "number" && Number.isFinite(e) && e >= 0, f = (e, n) => {
|
|
16
|
-
let r = e.edition, i = e.entitlements, a = e.features;
|
|
17
|
-
if (typeof r != "string" || typeof i != "object" || !i) return t.fail(new c({
|
|
18
|
-
reason: "malformed",
|
|
19
|
-
message: "missing edition/entitlements"
|
|
20
|
-
}));
|
|
21
|
-
let o = {};
|
|
22
|
-
for (let [e, n] of Object.entries(i)) {
|
|
23
|
-
if (!d(n)) return t.fail(new c({
|
|
24
|
-
reason: "malformed",
|
|
25
|
-
message: `bad entitlement '${e}'`
|
|
26
|
-
}));
|
|
27
|
-
o[e] = n;
|
|
28
|
-
}
|
|
29
|
-
let s = Array.isArray(a) ? a.filter((e) => typeof e == "string") : [], l = e.plugins, u = Array.isArray(l) ? l.filter((e) => typeof e == "string") : [];
|
|
30
|
-
return t.succeed({
|
|
31
|
-
...typeof e.tenant == "string" ? { tenant: e.tenant } : {},
|
|
32
|
-
edition: r,
|
|
33
|
-
entitlements: o,
|
|
34
|
-
features: s,
|
|
35
|
-
plugins: u,
|
|
36
|
-
exp: typeof e.exp == "number" ? e.exp : 0,
|
|
37
|
-
...typeof e.iat == "number" ? { iat: e.iat } : {},
|
|
38
|
-
...n === void 0 ? {} : { kid: n }
|
|
39
|
-
});
|
|
40
|
-
}, p = (e, n) => t.tryPromise({
|
|
41
|
-
try: () => o(n, "EdDSA"),
|
|
42
|
-
catch: (e) => new c({
|
|
43
|
-
reason: "key",
|
|
44
|
-
message: e instanceof Error ? e.message : String(e)
|
|
45
|
-
})
|
|
46
|
-
}).pipe(t.flatMap((n) => t.tryPromise({
|
|
47
|
-
try: async () => {
|
|
48
|
-
let { payload: t, protectedHeader: r } = await s(e, n, { algorithms: ["EdDSA"] });
|
|
49
|
-
return {
|
|
50
|
-
payload: t,
|
|
51
|
-
kid: r.kid
|
|
52
|
-
};
|
|
53
|
-
},
|
|
54
|
-
catch: u
|
|
55
|
-
})), t.flatMap(({ payload: e, kid: t }) => f(e, t))), m = (e, t = {}) => {
|
|
56
|
-
let n = new Set(e.features), r = new Set(e.plugins), i = t.graceSeconds ?? 0, a = t.now ?? (() => Date.now() / 1e3), o = () => e.exp <= 0 || a() <= e.exp + i;
|
|
57
|
-
return {
|
|
58
|
-
claims: e,
|
|
59
|
-
state: () => e.exp <= 0 || a() <= e.exp ? "active" : o() ? "grace" : "expired",
|
|
60
|
-
limitFor: (t, n) => {
|
|
61
|
-
if (!o() || e.tenant !== void 0 && e.tenant !== t) return;
|
|
62
|
-
let r = e.entitlements[n];
|
|
63
|
-
if (r !== void 0) return r === "unlimited" ? Infinity : r;
|
|
64
|
-
},
|
|
65
|
-
hasFeature: (e) => o() && n.has(e),
|
|
66
|
-
allowsPlugin: (e) => o() && r.has(e)
|
|
67
|
-
};
|
|
68
|
-
}, h, g = (e) => {
|
|
69
|
-
h = e;
|
|
70
|
-
}, _ = () => h, v, y = (e) => {
|
|
71
|
-
v = e;
|
|
72
|
-
}, b = () => v, x = (e, n) => t.sync(() => {
|
|
73
|
-
let t = _()?.limitFor(e, n);
|
|
74
|
-
return t === void 0 ? null : t;
|
|
75
|
-
}), S = () => {
|
|
76
|
-
let e = _(), t = e === void 0 ? void 0 : {
|
|
1
|
+
import { fetchLicenseSnapshot as e } from "./refresh.js";
|
|
2
|
+
import { LicenseVerifyError as t, verifyLicense as n } from "./verify.js";
|
|
3
|
+
import { getRejection as r, getSnapshot as i, makeSnapshot as a, setRejection as o, setSnapshot as s } from "./snapshot.js";
|
|
4
|
+
import { entitlementResolver as c } from "./resolver.js";
|
|
5
|
+
import { Data as l, Effect as u } from "effect";
|
|
6
|
+
import { definePlugin as d, definePluginService as f, pluginInstanceName as p } from "@voltro/protocol";
|
|
7
|
+
import { pluginEnv as m } from "@voltro/env";
|
|
8
|
+
//#region src/index.ts
|
|
9
|
+
var h = () => {
|
|
10
|
+
let e = i(), t = e === void 0 ? void 0 : {
|
|
77
11
|
state: e.state(),
|
|
78
12
|
edition: e.claims.edition,
|
|
79
13
|
expiresAt: e.claims.exp,
|
|
80
14
|
...e.claims.tenant === void 0 ? {} : { tenant: e.claims.tenant }
|
|
81
|
-
}, n =
|
|
15
|
+
}, n = r();
|
|
82
16
|
return n === void 0 ? t ?? { state: "none" } : {
|
|
83
17
|
state: "rejected",
|
|
84
18
|
reason: n.reason,
|
|
@@ -89,7 +23,7 @@ var c = class extends e.TaggedError("LicenseVerifyError") {}, l = (e) => typeof
|
|
|
89
23
|
expiresAt: t.expiresAt
|
|
90
24
|
} }
|
|
91
25
|
};
|
|
92
|
-
},
|
|
26
|
+
}, g = m([{
|
|
93
27
|
name: "VOLTRO_LICENSE_PUBLIC_KEY",
|
|
94
28
|
required: !1,
|
|
95
29
|
secret: !1,
|
|
@@ -99,66 +33,59 @@ var c = class extends e.TaggedError("LicenseVerifyError") {}, l = (e) => typeof
|
|
|
99
33
|
required: !1,
|
|
100
34
|
secret: !1,
|
|
101
35
|
description: "The signed EdDSA license token, verified offline at boot."
|
|
102
|
-
}]), { Tag:
|
|
103
|
-
snapshot: () =>
|
|
104
|
-
status: () =>
|
|
105
|
-
limitFor: (e, t) =>
|
|
106
|
-
hasFeature: (e) =>
|
|
107
|
-
allowsPlugin: (e) =>
|
|
108
|
-
edition: () =>
|
|
109
|
-
}),
|
|
110
|
-
let r =
|
|
36
|
+
}]), { Tag: _, Live: v } = f("@voltro/plugin-licensing/LicenseService", {
|
|
37
|
+
snapshot: () => i(),
|
|
38
|
+
status: () => h(),
|
|
39
|
+
limitFor: (e, t) => i()?.limitFor(e, t),
|
|
40
|
+
hasFeature: (e) => i()?.hasFeature(e) ?? !1,
|
|
41
|
+
allowsPlugin: (e) => i()?.allowsPlugin(e) ?? !1,
|
|
42
|
+
edition: () => i()?.claims.edition
|
|
43
|
+
}), y = class extends l.TaggedError("PluginNotLicensed") {}, b = (e) => i()?.allowsPlugin(e) ?? !1, x = (e) => b(e) ? u.void : u.fail(new y({ plugin: e })), S = (t = {}) => {
|
|
44
|
+
let r = g.read("VOLTRO_LICENSE_PUBLIC_KEY", t.publicKey), i = g.read("VOLTRO_LICENSE_KEY", t.licenseKey), c = t.graceSeconds ?? 0, l = t.refreshMs ?? 9e5, f, m = (e, t) => n(e, t).pipe(u.match({
|
|
111
45
|
onSuccess: (e) => {
|
|
112
|
-
|
|
46
|
+
s(a(e, { graceSeconds: c })), o(void 0), f?.info("license active", {
|
|
113
47
|
edition: e.edition,
|
|
114
48
|
entitlements: Object.keys(e.entitlements).length,
|
|
115
49
|
exp: e.exp
|
|
116
50
|
});
|
|
117
51
|
},
|
|
118
52
|
onFailure: (e) => {
|
|
119
|
-
|
|
53
|
+
o({
|
|
120
54
|
reason: e.reason,
|
|
121
55
|
message: e.message
|
|
122
|
-
}),
|
|
56
|
+
}), f?.warn("license verification failed — entitlements fall back to static plan", {
|
|
123
57
|
reason: e.reason,
|
|
124
58
|
message: e.message
|
|
125
59
|
});
|
|
126
60
|
}
|
|
127
61
|
}));
|
|
128
|
-
return
|
|
129
|
-
name:
|
|
62
|
+
return d({
|
|
63
|
+
name: p({
|
|
130
64
|
base: "@voltro/plugin-licensing",
|
|
131
|
-
instance:
|
|
65
|
+
instance: t.name
|
|
132
66
|
}),
|
|
133
67
|
description: "Offline-verified EdDSA license keys + entitlement snapshots; feeds @voltro/plugin-billing via entitlementResolver.",
|
|
134
|
-
declaredEnv:
|
|
135
|
-
services:
|
|
136
|
-
onActivate: (e) =>
|
|
137
|
-
|
|
138
|
-
e.logger.debug("licensing: no license configured — static plan entitlements apply");
|
|
139
|
-
return;
|
|
140
|
-
}
|
|
141
|
-
yield* l(a, r);
|
|
68
|
+
declaredEnv: g.declared,
|
|
69
|
+
services: v,
|
|
70
|
+
onActivate: (e) => u.gen(function* () {
|
|
71
|
+
f = e.logger, r === void 0 || i === void 0 ? e.logger.debug("licensing: no license configured — static plan entitlements apply") : yield* m(i, r);
|
|
142
72
|
}),
|
|
143
73
|
bindDataStore: (n, i) => {
|
|
144
|
-
let a =
|
|
74
|
+
let a = t.snapshotUrl;
|
|
145
75
|
if (a === void 0 || r === void 0 || i?.scheduleCoordinated === void 0) return;
|
|
146
76
|
let o = r;
|
|
147
|
-
i.scheduleCoordinated("licensing.refresh",
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
} catch (e) {
|
|
154
|
-
c?.warn("license refresh failed", { message: e instanceof Error ? e.message : String(e) });
|
|
155
|
-
}
|
|
77
|
+
i.scheduleCoordinated("licensing.refresh", l, async () => {
|
|
78
|
+
let n = await e(a, t.snapshotHeaders);
|
|
79
|
+
n.kind === "token" ? await u.runPromise(m(n.token, o)) : f?.warn("license refresh failed — retaining the last verified snapshot", {
|
|
80
|
+
reason: n.reason,
|
|
81
|
+
...n.reason === "http" ? { status: n.status } : {}
|
|
82
|
+
});
|
|
156
83
|
});
|
|
157
84
|
},
|
|
158
|
-
onDeactivate: () =>
|
|
159
|
-
|
|
85
|
+
onDeactivate: () => u.sync(() => {
|
|
86
|
+
s(void 0), o(void 0);
|
|
160
87
|
})
|
|
161
88
|
});
|
|
162
89
|
};
|
|
163
90
|
//#endregion
|
|
164
|
-
export {
|
|
91
|
+
export { _ as LicenseService, t as LicenseVerifyError, y as PluginNotLicensed, c as entitlementResolver, b as licenseAllowsPlugin, h as licenseStatus, S as licensingPlugin, x as requireLicensedPlugin, n as verifyLicense };
|
package/dist/refresh.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
//#region src/refresh.ts
|
|
2
|
+
var e = async (e, t = {}) => {
|
|
3
|
+
try {
|
|
4
|
+
let n = await fetch(e, {
|
|
5
|
+
headers: t,
|
|
6
|
+
redirect: "error",
|
|
7
|
+
signal: AbortSignal.timeout(1e4)
|
|
8
|
+
});
|
|
9
|
+
if (!n.ok) return await n.body?.cancel(), {
|
|
10
|
+
kind: "refused",
|
|
11
|
+
reason: "http",
|
|
12
|
+
status: n.status
|
|
13
|
+
};
|
|
14
|
+
if (n.body === null) return {
|
|
15
|
+
kind: "refused",
|
|
16
|
+
reason: "response"
|
|
17
|
+
};
|
|
18
|
+
let r = n.body.getReader(), i = new TextDecoder("utf-8", { fatal: !0 }), a = 0, o = "";
|
|
19
|
+
try {
|
|
20
|
+
for (;;) {
|
|
21
|
+
let e = await r.read();
|
|
22
|
+
if (e.done) break;
|
|
23
|
+
if (a += e.value.byteLength, a > 65536) return await r.cancel(), {
|
|
24
|
+
kind: "refused",
|
|
25
|
+
reason: "response"
|
|
26
|
+
};
|
|
27
|
+
o += i.decode(e.value, { stream: !0 });
|
|
28
|
+
}
|
|
29
|
+
o += i.decode();
|
|
30
|
+
} finally {
|
|
31
|
+
r.releaseLock();
|
|
32
|
+
}
|
|
33
|
+
let s;
|
|
34
|
+
try {
|
|
35
|
+
s = JSON.parse(o);
|
|
36
|
+
} catch {
|
|
37
|
+
return {
|
|
38
|
+
kind: "refused",
|
|
39
|
+
reason: "response"
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
return typeof s != "object" || !s || Array.isArray(s) || !("token" in s) || typeof s.token != "string" || s.token.trim() === "" ? {
|
|
43
|
+
kind: "refused",
|
|
44
|
+
reason: "response"
|
|
45
|
+
} : {
|
|
46
|
+
kind: "token",
|
|
47
|
+
token: s.token
|
|
48
|
+
};
|
|
49
|
+
} catch {
|
|
50
|
+
return {
|
|
51
|
+
kind: "refused",
|
|
52
|
+
reason: "transport"
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
//#endregion
|
|
57
|
+
export { e as fetchLicenseSnapshot };
|
package/dist/resolver.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { getSnapshot as e } from "./snapshot.js";
|
|
2
|
+
import { Effect as t } from "effect";
|
|
3
|
+
//#region src/resolver.ts
|
|
4
|
+
var n = (n, r) => t.sync(() => {
|
|
5
|
+
let t = e()?.limitFor(n, r);
|
|
6
|
+
return t === void 0 ? null : t;
|
|
7
|
+
});
|
|
8
|
+
//#endregion
|
|
9
|
+
export { n as entitlementResolver };
|
package/dist/snapshot.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
//#region src/snapshot.ts
|
|
2
|
+
var e = (e, t = {}) => {
|
|
3
|
+
let n = new Set(e.features), r = new Set(e.plugins), i = t.graceSeconds ?? 0, a = t.now ?? (() => Date.now() / 1e3), o = () => e.exp <= 0 || a() <= e.exp + i;
|
|
4
|
+
return {
|
|
5
|
+
claims: e,
|
|
6
|
+
state: () => e.exp <= 0 || a() <= e.exp ? "active" : o() ? "grace" : "expired",
|
|
7
|
+
limitFor: (t, n) => {
|
|
8
|
+
if (!o() || e.tenant !== void 0 && e.tenant !== t) return;
|
|
9
|
+
let r = e.entitlements[n];
|
|
10
|
+
if (r !== void 0) return r === "unlimited" ? Infinity : r;
|
|
11
|
+
},
|
|
12
|
+
hasFeature: (e) => o() && n.has(e),
|
|
13
|
+
allowsPlugin: (e) => o() && r.has(e)
|
|
14
|
+
};
|
|
15
|
+
}, t, n = (e) => {
|
|
16
|
+
t = e;
|
|
17
|
+
}, r = () => t, i, a = (e) => {
|
|
18
|
+
i = e;
|
|
19
|
+
}, o = () => i;
|
|
20
|
+
//#endregion
|
|
21
|
+
export { o as getRejection, r as getSnapshot, e as makeSnapshot, a as setRejection, n as setSnapshot };
|
package/dist/verify.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { Data as e, Effect as t } from "effect";
|
|
2
|
+
import { importSPKI as n, jwtVerify as r } from "jose";
|
|
3
|
+
//#region src/verify.ts
|
|
4
|
+
var i = class extends e.TaggedError("LicenseVerifyError") {}, a = (e) => typeof e == "object" && e && "code" in e ? String(e.code) : "", o = (e) => {
|
|
5
|
+
let t = e instanceof Error ? e.message : String(e);
|
|
6
|
+
return a(e) === "ERR_JWT_EXPIRED" ? new i({
|
|
7
|
+
reason: "expired",
|
|
8
|
+
message: t
|
|
9
|
+
}) : new i({
|
|
10
|
+
reason: "invalid",
|
|
11
|
+
message: t
|
|
12
|
+
});
|
|
13
|
+
}, s = (e) => e === "unlimited" || typeof e == "number" && Number.isFinite(e) && e >= 0, c = (e, n) => {
|
|
14
|
+
let r = e.edition, a = e.entitlements, o = e.features;
|
|
15
|
+
if (typeof r != "string" || typeof a != "object" || !a) return t.fail(new i({
|
|
16
|
+
reason: "malformed",
|
|
17
|
+
message: "missing edition/entitlements"
|
|
18
|
+
}));
|
|
19
|
+
let c = {};
|
|
20
|
+
for (let [e, n] of Object.entries(a)) {
|
|
21
|
+
if (!s(n)) return t.fail(new i({
|
|
22
|
+
reason: "malformed",
|
|
23
|
+
message: `bad entitlement '${e}'`
|
|
24
|
+
}));
|
|
25
|
+
c[e] = n;
|
|
26
|
+
}
|
|
27
|
+
let l = Array.isArray(o) ? o.filter((e) => typeof e == "string") : [], u = e.plugins, d = Array.isArray(u) ? u.filter((e) => typeof e == "string") : [];
|
|
28
|
+
return t.succeed({
|
|
29
|
+
...typeof e.tenant == "string" ? { tenant: e.tenant } : {},
|
|
30
|
+
edition: r,
|
|
31
|
+
entitlements: c,
|
|
32
|
+
features: l,
|
|
33
|
+
plugins: d,
|
|
34
|
+
exp: typeof e.exp == "number" ? e.exp : 0,
|
|
35
|
+
...typeof e.iat == "number" ? { iat: e.iat } : {},
|
|
36
|
+
...n === void 0 ? {} : { kid: n }
|
|
37
|
+
});
|
|
38
|
+
}, l = (e, a) => t.tryPromise({
|
|
39
|
+
try: () => n(a, "EdDSA"),
|
|
40
|
+
catch: (e) => new i({
|
|
41
|
+
reason: "key",
|
|
42
|
+
message: e instanceof Error ? e.message : String(e)
|
|
43
|
+
})
|
|
44
|
+
}).pipe(t.flatMap((n) => t.tryPromise({
|
|
45
|
+
try: async () => {
|
|
46
|
+
let { payload: t, protectedHeader: i } = await r(e, n, { algorithms: ["EdDSA"] });
|
|
47
|
+
return {
|
|
48
|
+
payload: t,
|
|
49
|
+
kid: i.kid
|
|
50
|
+
};
|
|
51
|
+
},
|
|
52
|
+
catch: o
|
|
53
|
+
})), t.flatMap(({ payload: e, kid: t }) => c(e, t)));
|
|
54
|
+
//#endregion
|
|
55
|
+
export { i as LicenseVerifyError, l as verifyLicense };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@voltro/plugin-licensing",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.86.0",
|
|
4
4
|
"description": "Offline-verified license keys + cloud-issued entitlement snapshots for @voltro apps",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"voltro",
|
|
@@ -38,14 +38,14 @@
|
|
|
38
38
|
"node": ">=24.0.0"
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"@voltro/database": "0.
|
|
42
|
-
"@voltro/env": "0.
|
|
43
|
-
"@voltro/logger": "0.
|
|
44
|
-
"@voltro/protocol": "0.
|
|
41
|
+
"@voltro/database": "0.86.0",
|
|
42
|
+
"@voltro/env": "0.86.0",
|
|
43
|
+
"@voltro/logger": "0.86.0",
|
|
44
|
+
"@voltro/protocol": "0.86.0",
|
|
45
45
|
"jose": "^6.2.12"
|
|
46
46
|
},
|
|
47
47
|
"peerDependencies": {
|
|
48
|
-
"effect": "
|
|
48
|
+
"effect": "4.0.1"
|
|
49
49
|
},
|
|
50
50
|
"publishConfig": {
|
|
51
51
|
"access": "public"
|