@madauth/server 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.
- package/LICENSE +21 -0
- package/README.md +50 -0
- package/dist/bin.js +121 -0
- package/dist/chunk-3WIWWKRI.js +81 -0
- package/dist/chunk-AVZE6FFR.js +99 -0
- package/dist/chunk-JDQLSL36.js +32 -0
- package/dist/chunk-PRRKLEW7.js +54 -0
- package/dist/chunk-TEMZRMFF.js +498 -0
- package/dist/chunk-V2XKGETK.js +386 -0
- package/dist/config-KQvE2imU.d.ts +65 -0
- package/dist/entry/azure.d.ts +13 -0
- package/dist/entry/azure.js +84 -0
- package/dist/entry/lambda.d.ts +17 -0
- package/dist/entry/lambda.js +25 -0
- package/dist/entry/node.d.ts +16 -0
- package/dist/entry/node.js +22 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.js +32 -0
- package/dist/schema-Dotc6LU7.d.ts +137 -0
- package/dist/sqlite.js +100 -0
- package/dist/standalone/azure.mjs +8377 -0
- package/dist/standalone/lambda.mjs +4462 -0
- package/dist/standalone/main.mjs +5548 -0
- package/dist/store/sqlite.d.ts +9 -0
- package/dist/testing.d.ts +26 -0
- package/dist/testing.js +99 -0
- package/dist/user-3DDyggFV.d.ts +10 -0
- package/dist/verify.d.ts +24 -0
- package/dist/verify.js +48 -0
- package/dist/webhook-CT80emTQ.d.ts +31 -0
- package/dist/webhook.d.ts +1 -0
- package/dist/webhook.js +14 -0
- package/package.json +109 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { S as StoreAdapter } from '../schema-Dotc6LU7.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Stores madAuth's records in a SQLite file using Node's built-in `node:sqlite` (Node 22.13 or newer).
|
|
5
|
+
* Creates the tables on first use. Pass `':memory:'` for a database that lives only in memory.
|
|
6
|
+
*/
|
|
7
|
+
declare function createSqliteAdapter(path: string): StoreAdapter;
|
|
8
|
+
|
|
9
|
+
export { createSqliteAdapter };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { S as StoreAdapter } from './schema-Dotc6LU7.js';
|
|
2
|
+
|
|
3
|
+
/** The parts of a test runner the contract needs; Vitest, Jest and node:test with `expect` all fit. */
|
|
4
|
+
interface ContractRunner {
|
|
5
|
+
describe(name: string, fn: () => void): void;
|
|
6
|
+
it(name: string, fn: () => Promise<void>): void;
|
|
7
|
+
expect(actual: unknown): {
|
|
8
|
+
toEqual(expected: unknown): void;
|
|
9
|
+
toBe(expected: unknown): void;
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Checks that a {@link StoreAdapter} behaves the way madAuth relies on. Run it from your own tests:
|
|
14
|
+
*
|
|
15
|
+
* ```ts
|
|
16
|
+
* import { describe, it, expect } from 'vitest';
|
|
17
|
+
* import { storeAdapterContract } from '@madauth/server/testing';
|
|
18
|
+
* storeAdapterContract({ describe, it, expect }, () => createMyAdapter(process.env.TEST_DB_URL!));
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* `createAdapter` is called once per test. The tests only add records with fresh ids, so a shared test
|
|
22
|
+
* database works as well as an empty one.
|
|
23
|
+
*/
|
|
24
|
+
declare function storeAdapterContract(t: ContractRunner, createAdapter: () => StoreAdapter | Promise<StoreAdapter>): void;
|
|
25
|
+
|
|
26
|
+
export { type ContractRunner, storeAdapterContract };
|
package/dist/testing.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// src/testing.ts
|
|
2
|
+
var counter = 0;
|
|
3
|
+
function unique(prefix) {
|
|
4
|
+
return `${prefix}_${Date.now().toString(36)}_${(counter++).toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
5
|
+
}
|
|
6
|
+
function testUser(overrides = {}) {
|
|
7
|
+
const id = unique("usr_contract");
|
|
8
|
+
return {
|
|
9
|
+
id,
|
|
10
|
+
email: `${id}@Example.com`,
|
|
11
|
+
emailNormalized: `${id}@example.com`,
|
|
12
|
+
emailVerified: false,
|
|
13
|
+
name: null,
|
|
14
|
+
sessionVersion: 0,
|
|
15
|
+
lastMailAt: 0,
|
|
16
|
+
createdAt: 17e11,
|
|
17
|
+
...overrides
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
function testVerification(userId) {
|
|
21
|
+
return { id: unique("ver"), userId, purpose: "reset", codeHash: "h", attempts: 0, expiresAt: 17e11 };
|
|
22
|
+
}
|
|
23
|
+
function storeAdapterContract(t, createAdapter) {
|
|
24
|
+
const { describe, it, expect } = t;
|
|
25
|
+
describe("madAuth store adapter contract", () => {
|
|
26
|
+
it("creates a record and finds it with the same types", async () => {
|
|
27
|
+
const store = await createAdapter();
|
|
28
|
+
const user = testUser({ emailVerified: true, name: "Ada", sessionVersion: 3 });
|
|
29
|
+
expect(await store.create("user", user)).toBe(true);
|
|
30
|
+
expect(await store.findOne("user", { id: user.id })).toEqual(user);
|
|
31
|
+
expect(await store.findOne("user", { id: unique("missing") })).toBe(null);
|
|
32
|
+
});
|
|
33
|
+
it("keeps false, 0 and null distinct", async () => {
|
|
34
|
+
const store = await createAdapter();
|
|
35
|
+
const user = testUser({ emailVerified: false, name: null, sessionVersion: 0 });
|
|
36
|
+
await store.create("user", user);
|
|
37
|
+
const found = await store.findOne("user", { id: user.id });
|
|
38
|
+
expect(found?.emailVerified).toBe(false);
|
|
39
|
+
expect(found?.name).toBe(null);
|
|
40
|
+
expect(found?.sessionVersion).toBe(0);
|
|
41
|
+
});
|
|
42
|
+
it("refuses a duplicate unique value or primary key and writes nothing", async () => {
|
|
43
|
+
const store = await createAdapter();
|
|
44
|
+
const user = testUser({ name: "Original" });
|
|
45
|
+
await store.create("user", user);
|
|
46
|
+
const sameEmail = testUser({ emailNormalized: user.emailNormalized, name: "Duplicate" });
|
|
47
|
+
expect(await store.create("user", sameEmail)).toBe(false);
|
|
48
|
+
expect(await store.findOne("user", { id: sameEmail.id })).toBe(null);
|
|
49
|
+
expect(await store.create("user", { ...testUser(), id: user.id, name: "Duplicate" })).toBe(false);
|
|
50
|
+
expect((await store.findOne("user", { id: user.id }))?.name).toBe("Original");
|
|
51
|
+
});
|
|
52
|
+
it("matches every field of the filter, including null", async () => {
|
|
53
|
+
const store = await createAdapter();
|
|
54
|
+
const user = testUser({ emailVerified: true, name: null });
|
|
55
|
+
await store.create("user", user);
|
|
56
|
+
expect((await store.findOne("user", { emailNormalized: user.emailNormalized, emailVerified: true }))?.id).toBe(user.id);
|
|
57
|
+
expect(await store.findOne("user", { emailNormalized: user.emailNormalized, emailVerified: false })).toBe(null);
|
|
58
|
+
expect((await store.findOne("user", { id: user.id, name: null }))?.id).toBe(user.id);
|
|
59
|
+
});
|
|
60
|
+
it("finds many records and honours the limit", async () => {
|
|
61
|
+
const store = await createAdapter();
|
|
62
|
+
const userId = unique("usr_contract");
|
|
63
|
+
for (let i = 0; i < 3; i++) await store.create("verification", testVerification(userId));
|
|
64
|
+
expect((await store.findMany("verification", { userId })).length).toBe(3);
|
|
65
|
+
expect((await store.findMany("verification", { userId }, { limit: 2 })).length).toBe(2);
|
|
66
|
+
expect(await store.findMany("verification", { userId: unique("nobody") })).toEqual([]);
|
|
67
|
+
});
|
|
68
|
+
it("updates only the given fields of the matching records and counts them", async () => {
|
|
69
|
+
const store = await createAdapter();
|
|
70
|
+
const user = testUser({ name: "Ada" });
|
|
71
|
+
const other = testUser({ name: "Grace" });
|
|
72
|
+
await store.create("user", user);
|
|
73
|
+
await store.create("user", other);
|
|
74
|
+
expect(await store.update("user", { id: user.id }, { emailVerified: true, sessionVersion: 1 })).toBe(1);
|
|
75
|
+
expect(await store.update("user", { id: unique("missing") }, { sessionVersion: 1 })).toBe(0);
|
|
76
|
+
expect(await store.findOne("user", { id: user.id })).toEqual({ ...user, emailVerified: true, sessionVersion: 1 });
|
|
77
|
+
expect(await store.findOne("user", { id: other.id })).toEqual(other);
|
|
78
|
+
});
|
|
79
|
+
it("deletes the matching records and counts them", async () => {
|
|
80
|
+
const store = await createAdapter();
|
|
81
|
+
const userId = unique("usr_contract");
|
|
82
|
+
const records = [testVerification(userId), testVerification(userId)];
|
|
83
|
+
for (const record of records) await store.create("verification", record);
|
|
84
|
+
expect(await store.delete("verification", { id: records[0].id })).toBe(1);
|
|
85
|
+
expect(await store.delete("verification", { id: records[0].id })).toBe(0);
|
|
86
|
+
expect(await store.findOne("verification", { id: records[1].id })).toEqual(records[1]);
|
|
87
|
+
});
|
|
88
|
+
it("reports a deleted record to only one of several concurrent deletes", async () => {
|
|
89
|
+
const store = await createAdapter();
|
|
90
|
+
const record = testVerification(unique("usr_contract"));
|
|
91
|
+
await store.create("verification", record);
|
|
92
|
+
const counts = await Promise.all(Array.from({ length: 10 }, () => store.delete("verification", { id: record.id })));
|
|
93
|
+
expect(counts.reduce((sum, n) => sum + n, 0)).toBe(1);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
export {
|
|
98
|
+
storeAdapterContract
|
|
99
|
+
};
|
package/dist/verify.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { JWTVerifyGetKey } from 'jose';
|
|
2
|
+
import { M as MadauthUser } from './user-3DDyggFV.js';
|
|
3
|
+
|
|
4
|
+
interface SessionVerifierOptions {
|
|
5
|
+
/** The madAuth server's public base URL (its MADAUTH_ISSUER). */
|
|
6
|
+
issuer: string;
|
|
7
|
+
/** Keys to verify with. Defaults to `<issuer>/.well-known/jwks.json` (fetched once, then cached). */
|
|
8
|
+
jwks?: JWTVerifyGetKey;
|
|
9
|
+
}
|
|
10
|
+
/** Verifies a madAuth session for an app backend. Resolves to the user, or null if there is no valid session. */
|
|
11
|
+
type SessionVerifier = (input: Request | string) => Promise<MadauthUser | null>;
|
|
12
|
+
/**
|
|
13
|
+
* Creates a function that checks madAuth sessions in your own backend (any Web-standard runtime).
|
|
14
|
+
* It accepts a `Request` (session cookie or `Authorization: Bearer`), a `Cookie` header or a bare token.
|
|
15
|
+
*
|
|
16
|
+
* ```ts
|
|
17
|
+
* const verifySession = createSessionVerifier({ issuer: 'https://auth.example.com' });
|
|
18
|
+
* const user = await verifySession(request);
|
|
19
|
+
* if (!user) return new Response('Unauthorized', { status: 401 });
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
declare function createSessionVerifier(opts: SessionVerifierOptions): SessionVerifier;
|
|
23
|
+
|
|
24
|
+
export { MadauthUser, type SessionVerifier, type SessionVerifierOptions, createSessionVerifier };
|
package/dist/verify.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import {
|
|
2
|
+
SESSION_COOKIE,
|
|
3
|
+
SESSION_TYP,
|
|
4
|
+
userFromClaims
|
|
5
|
+
} from "./chunk-PRRKLEW7.js";
|
|
6
|
+
import {
|
|
7
|
+
SIGNING_ALG
|
|
8
|
+
} from "./chunk-JDQLSL36.js";
|
|
9
|
+
|
|
10
|
+
// src/verify.ts
|
|
11
|
+
import { createRemoteJWKSet, jwtVerify } from "jose";
|
|
12
|
+
function tokenFromCookieHeader(header) {
|
|
13
|
+
for (const part of header?.split(";") ?? []) {
|
|
14
|
+
const [name, ...value] = part.trim().split("=");
|
|
15
|
+
if (name === SESSION_COOKIE) return decodeURIComponent(value.join("="));
|
|
16
|
+
}
|
|
17
|
+
return void 0;
|
|
18
|
+
}
|
|
19
|
+
function tokenFrom(input) {
|
|
20
|
+
if (typeof input !== "string") {
|
|
21
|
+
const auth = input.headers.get("authorization");
|
|
22
|
+
if (auth?.toLowerCase().startsWith("bearer ")) return auth.slice(7).trim();
|
|
23
|
+
return tokenFromCookieHeader(input.headers.get("cookie"));
|
|
24
|
+
}
|
|
25
|
+
return input.includes(`${SESSION_COOKIE}=`) ? tokenFromCookieHeader(input) : input.trim() || void 0;
|
|
26
|
+
}
|
|
27
|
+
function createSessionVerifier(opts) {
|
|
28
|
+
const issuer = opts.issuer.replace(/\/+$/, "");
|
|
29
|
+
const keys = opts.jwks ?? createRemoteJWKSet(new URL(`${issuer}/.well-known/jwks.json`));
|
|
30
|
+
return async (input) => {
|
|
31
|
+
try {
|
|
32
|
+
const token = tokenFrom(input);
|
|
33
|
+
if (!token) return null;
|
|
34
|
+
const { payload } = await jwtVerify(token, keys, {
|
|
35
|
+
issuer,
|
|
36
|
+
typ: SESSION_TYP,
|
|
37
|
+
algorithms: [SIGNING_ALG]
|
|
38
|
+
});
|
|
39
|
+
if (typeof payload.sub !== "string") return null;
|
|
40
|
+
return userFromClaims(payload);
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
export {
|
|
47
|
+
createSessionVerifier
|
|
48
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** Every `type` madAuth sends. */
|
|
2
|
+
declare const WEBHOOK_TYPES: readonly ["email.verify", "email.reset", "email.already_registered", "signup.before", "user.created", "email.verified", "password.reset", "user.signed_in"];
|
|
3
|
+
type WebhookType = (typeof WEBHOOK_TYPES)[number];
|
|
4
|
+
/** How old a call may be before receivers reject it, so a captured call can't be replayed. */
|
|
5
|
+
declare const WEBHOOK_TOLERANCE_SECONDS: number;
|
|
6
|
+
/** A new secret for WEBHOOK_SECRET: `whsec_` and 32 random bytes in base64. */
|
|
7
|
+
declare function generateWebhookSecret(): string;
|
|
8
|
+
/** The `webhook-signature` header value for a call: `v1,<base64 HMAC-SHA256 of "id.timestamp.body">`. */
|
|
9
|
+
declare function signWebhook(secret: string, id: string, timestamp: number, body: string): string;
|
|
10
|
+
type HeaderSource = Headers | Record<string, string | string[] | undefined>;
|
|
11
|
+
/**
|
|
12
|
+
* Checks that a webhook call came from madAuth: the signature matches `body` (the raw request body, as
|
|
13
|
+
* received) and the call is at most five minutes old. For receivers written in JavaScript:
|
|
14
|
+
*
|
|
15
|
+
* ```ts
|
|
16
|
+
* import { verifyWebhook } from '@madauth/server/webhook';
|
|
17
|
+
* if (!verifyWebhook(process.env.WEBHOOK_SECRET!, request.headers, rawBody)) return new Response(null, { status: 401 });
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
declare function verifyWebhook(secret: string, headers: HeaderSource, body: string, options?: {
|
|
21
|
+
now?: number;
|
|
22
|
+
toleranceSeconds?: number;
|
|
23
|
+
}): boolean;
|
|
24
|
+
interface WebhookSettings {
|
|
25
|
+
url: string;
|
|
26
|
+
secret: string;
|
|
27
|
+
/** Types to send besides the e-mails (WEBHOOK_EVENTS); null sends every type. */
|
|
28
|
+
events: ReadonlySet<string> | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export { type WebhookSettings as W, WEBHOOK_TOLERANCE_SECONDS as a, WEBHOOK_TYPES as b, type WebhookType as c, generateWebhookSecret as g, signWebhook as s, verifyWebhook as v };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { a as WEBHOOK_TOLERANCE_SECONDS, b as WEBHOOK_TYPES, c as WebhookType, g as generateWebhookSecret, s as signWebhook, v as verifyWebhook } from './webhook-CT80emTQ.js';
|
package/dist/webhook.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import {
|
|
2
|
+
WEBHOOK_TOLERANCE_SECONDS,
|
|
3
|
+
WEBHOOK_TYPES,
|
|
4
|
+
generateWebhookSecret,
|
|
5
|
+
signWebhook,
|
|
6
|
+
verifyWebhook
|
|
7
|
+
} from "./chunk-AVZE6FFR.js";
|
|
8
|
+
export {
|
|
9
|
+
WEBHOOK_TOLERANCE_SECONDS,
|
|
10
|
+
WEBHOOK_TYPES,
|
|
11
|
+
generateWebhookSecret,
|
|
12
|
+
signWebhook,
|
|
13
|
+
verifyWebhook
|
|
14
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@madauth/server",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "madAuth server: Google and e-mail & password sign-in with madAuth sessions, for Docker, AWS Lambda and Azure Functions.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "David Neuy",
|
|
7
|
+
"homepage": "https://github.com/inouiw/madAuth#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/inouiw/madAuth.git",
|
|
11
|
+
"directory": "packages/server"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/inouiw/madAuth/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"auth",
|
|
18
|
+
"authentication",
|
|
19
|
+
"login",
|
|
20
|
+
"sign-in",
|
|
21
|
+
"google-sign-in",
|
|
22
|
+
"session",
|
|
23
|
+
"jwt",
|
|
24
|
+
"hono",
|
|
25
|
+
"aws-lambda",
|
|
26
|
+
"azure-functions"
|
|
27
|
+
],
|
|
28
|
+
"type": "module",
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=22.13"
|
|
31
|
+
},
|
|
32
|
+
"main": "./dist/index.js",
|
|
33
|
+
"types": "./dist/index.d.ts",
|
|
34
|
+
"exports": {
|
|
35
|
+
".": {
|
|
36
|
+
"types": "./dist/index.d.ts",
|
|
37
|
+
"import": "./dist/index.js"
|
|
38
|
+
},
|
|
39
|
+
"./verify": {
|
|
40
|
+
"types": "./dist/verify.d.ts",
|
|
41
|
+
"import": "./dist/verify.js"
|
|
42
|
+
},
|
|
43
|
+
"./sqlite": {
|
|
44
|
+
"types": "./dist/store/sqlite.d.ts",
|
|
45
|
+
"import": "./dist/sqlite.js"
|
|
46
|
+
},
|
|
47
|
+
"./testing": {
|
|
48
|
+
"types": "./dist/testing.d.ts",
|
|
49
|
+
"import": "./dist/testing.js"
|
|
50
|
+
},
|
|
51
|
+
"./webhook": {
|
|
52
|
+
"types": "./dist/webhook.d.ts",
|
|
53
|
+
"import": "./dist/webhook.js"
|
|
54
|
+
},
|
|
55
|
+
"./node": {
|
|
56
|
+
"types": "./dist/entry/node.d.ts",
|
|
57
|
+
"import": "./dist/entry/node.js"
|
|
58
|
+
},
|
|
59
|
+
"./lambda": {
|
|
60
|
+
"types": "./dist/entry/lambda.d.ts",
|
|
61
|
+
"import": "./dist/entry/lambda.js"
|
|
62
|
+
},
|
|
63
|
+
"./azure": {
|
|
64
|
+
"types": "./dist/entry/azure.d.ts",
|
|
65
|
+
"import": "./dist/entry/azure.js"
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
"bin": {
|
|
69
|
+
"madauth-server": "dist/bin.js"
|
|
70
|
+
},
|
|
71
|
+
"files": [
|
|
72
|
+
"dist"
|
|
73
|
+
],
|
|
74
|
+
"scripts": {
|
|
75
|
+
"build": "tsup",
|
|
76
|
+
"dev": "tsx watch --env-file-if-exists=.env src/main.ts",
|
|
77
|
+
"start": "node --env-file-if-exists=.env dist/standalone/main.mjs",
|
|
78
|
+
"typecheck": "tsc --noEmit",
|
|
79
|
+
"test": "vitest run",
|
|
80
|
+
"test:watch": "vitest",
|
|
81
|
+
"generate-key": "tsx src/bin.ts generate-key",
|
|
82
|
+
"cli": "tsx --env-file-if-exists=.env src/bin.ts"
|
|
83
|
+
},
|
|
84
|
+
"dependencies": {
|
|
85
|
+
"@hono/node-server": "^2.1.3",
|
|
86
|
+
"hono": "^4.13.12",
|
|
87
|
+
"jose": "^6.2.12"
|
|
88
|
+
},
|
|
89
|
+
"peerDependencies": {
|
|
90
|
+
"@azure/functions": "^4.0.0"
|
|
91
|
+
},
|
|
92
|
+
"peerDependenciesMeta": {
|
|
93
|
+
"@azure/functions": {
|
|
94
|
+
"optional": true
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
"devDependencies": {
|
|
98
|
+
"@azure/functions": "^4.16.5",
|
|
99
|
+
"@types/aws-lambda": "^8.10.164",
|
|
100
|
+
"@types/node": "^26.6.4",
|
|
101
|
+
"tsup": "^8.5.1",
|
|
102
|
+
"tsx": "^4.23.15",
|
|
103
|
+
"typescript": "^5.9.3",
|
|
104
|
+
"vitest": "^5.0.3"
|
|
105
|
+
},
|
|
106
|
+
"publishConfig": {
|
|
107
|
+
"access": "public"
|
|
108
|
+
}
|
|
109
|
+
}
|