@vex-chat/spire 1.3.7 → 1.5.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 +4 -0
- package/dist/Database.d.ts +36 -1
- package/dist/Database.js +109 -0
- package/dist/Database.js.map +1 -1
- package/dist/Spire.d.ts +7 -0
- package/dist/Spire.js +7 -0
- package/dist/Spire.js.map +1 -1
- package/dist/db/schema.d.ts +16 -0
- package/dist/migrations/2026-05-03_passkeys.d.ts +8 -0
- package/dist/migrations/2026-05-03_passkeys.js +44 -0
- package/dist/migrations/2026-05-03_passkeys.js.map +1 -0
- package/dist/server/index.d.ts +7 -0
- package/dist/server/index.js +63 -9
- package/dist/server/index.js.map +1 -1
- package/dist/server/passkey.d.ts +7 -0
- package/dist/server/passkey.js +449 -0
- package/dist/server/passkey.js.map +1 -0
- package/dist/server/passkeyDevices.d.ts +25 -0
- package/dist/server/passkeyDevices.js +118 -0
- package/dist/server/passkeyDevices.js.map +1 -0
- package/dist/server/user.d.ts +26 -1
- package/dist/server/user.js +84 -0
- package/dist/server/user.js.map +1 -1
- package/dist/server/wellKnown.d.ts +57 -0
- package/dist/server/wellKnown.js +122 -0
- package/dist/server/wellKnown.js.map +1 -0
- package/dist/types/express.d.ts +10 -0
- package/dist/types/express.js.map +1 -1
- package/package.json +6 -5
- package/src/Database.ts +134 -1
- package/src/Spire.ts +7 -0
- package/src/__tests__/passkeys.spec.ts +217 -0
- package/src/__tests__/wellKnown.spec.ts +113 -0
- package/src/db/schema.ts +18 -1
- package/src/migrations/2026-05-03_passkeys.ts +52 -0
- package/src/server/index.ts +68 -9
- package/src/server/passkey.ts +589 -0
- package/src/server/passkeyDevices.ts +154 -0
- package/src/server/user.ts +105 -1
- package/src/server/wellKnown.ts +144 -0
- package/src/types/express.ts +8 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2020-2026 Vex Heavy Industries LLC
|
|
3
|
+
* Licensed under AGPL-3.0. See LICENSE for details.
|
|
4
|
+
* Commercial licenses available at vex.wtf
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Database } from "../Database.ts";
|
|
8
|
+
|
|
9
|
+
import express from "express";
|
|
10
|
+
|
|
11
|
+
import { msgpack } from "../utils/msgpack.ts";
|
|
12
|
+
|
|
13
|
+
import { resolveDeviceEnrollmentRequest } from "./user.ts";
|
|
14
|
+
import { getParam, getUser } from "./utils.ts";
|
|
15
|
+
|
|
16
|
+
import { protectPasskey } from "./index.ts";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Routes that grant a passkey-authenticated session a strictly
|
|
20
|
+
* bounded admin/recovery surface:
|
|
21
|
+
*
|
|
22
|
+
* - `GET /user/:id/passkey/devices` — list
|
|
23
|
+
* - `DELETE /user/:id/passkey/devices/:deviceID` — remove
|
|
24
|
+
* - `POST /user/:id/passkey/devices/requests/:requestID/approve` — approve
|
|
25
|
+
* - `POST /user/:id/passkey/devices/requests/:requestID/reject` — reject
|
|
26
|
+
*
|
|
27
|
+
* The route family is parallel to `/user/:id/devices/...` so the
|
|
28
|
+
* existing device-authenticated flow stays untouched (and there's no
|
|
29
|
+
* confusion about which kind of credential is doing what when a
|
|
30
|
+
* single endpoint accepts both).
|
|
31
|
+
*
|
|
32
|
+
* Mail/server/permissions/etc. routes are intentionally NOT mirrored
|
|
33
|
+
* here — passkeys are an administrative credential, not a messaging
|
|
34
|
+
* device.
|
|
35
|
+
*/
|
|
36
|
+
export const getPasskeyDeviceRouter = (
|
|
37
|
+
db: Database,
|
|
38
|
+
notify: (
|
|
39
|
+
userID: string,
|
|
40
|
+
event: string,
|
|
41
|
+
transmissionID: string,
|
|
42
|
+
data?: unknown,
|
|
43
|
+
deviceID?: string,
|
|
44
|
+
) => void,
|
|
45
|
+
) => {
|
|
46
|
+
const router = express.Router();
|
|
47
|
+
|
|
48
|
+
router.get(
|
|
49
|
+
"/user/:id/passkey/devices",
|
|
50
|
+
protectPasskey,
|
|
51
|
+
async (req, res) => {
|
|
52
|
+
const userDetails = getUser(req);
|
|
53
|
+
const userID = getParam(req, "id");
|
|
54
|
+
if (userDetails.userID !== userID) {
|
|
55
|
+
res.sendStatus(401);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const list = await db.retrieveUserDeviceList([userID]);
|
|
59
|
+
res.send(msgpack.encode(list));
|
|
60
|
+
},
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
router.delete(
|
|
64
|
+
"/user/:id/passkey/devices/:deviceID",
|
|
65
|
+
protectPasskey,
|
|
66
|
+
async (req, res) => {
|
|
67
|
+
const userDetails = getUser(req);
|
|
68
|
+
const userID = getParam(req, "id");
|
|
69
|
+
const deviceID = getParam(req, "deviceID");
|
|
70
|
+
if (userDetails.userID !== userID) {
|
|
71
|
+
res.sendStatus(401);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const device = await db.retrieveDevice(deviceID);
|
|
75
|
+
if (!device || device.owner !== userID) {
|
|
76
|
+
res.sendStatus(404);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
// The device-auth `DELETE /user/:id/devices/:deviceID`
|
|
80
|
+
// refuses to delete the user's last device (a device
|
|
81
|
+
// can't lock itself out). Passkeys may delete the last
|
|
82
|
+
// device on purpose: that's the entire recovery story —
|
|
83
|
+
// "I lost my phone, sign in with the passkey, wipe the
|
|
84
|
+
// old device, then enroll a new one with the passkey
|
|
85
|
+
// standing in as the approver."
|
|
86
|
+
await db.deleteDevice(deviceID);
|
|
87
|
+
// Tell whoever's online that the device-list shape
|
|
88
|
+
// changed; clients use this to refresh the Settings →
|
|
89
|
+
// Devices view in real time.
|
|
90
|
+
notify(userID, "deviceListChanged", crypto.randomUUID());
|
|
91
|
+
res.sendStatus(200);
|
|
92
|
+
},
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
router.post(
|
|
96
|
+
"/user/:id/passkey/devices/requests/:requestID/approve",
|
|
97
|
+
protectPasskey,
|
|
98
|
+
async (req, res) => {
|
|
99
|
+
const userDetails = getUser(req);
|
|
100
|
+
const userID = getParam(req, "id");
|
|
101
|
+
const requestID = getParam(req, "requestID");
|
|
102
|
+
if (userDetails.userID !== userID) {
|
|
103
|
+
res.sendStatus(401);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
// No second-factor signature here: the passkey JWT itself
|
|
107
|
+
// is fresh proof of user presence (5 min TTL, freshly
|
|
108
|
+
// minted from a WebAuthn ceremony with userVerification).
|
|
109
|
+
// Reusing it within those 5 minutes to approve an
|
|
110
|
+
// enrollment is fine — the equivalent guarantee that the
|
|
111
|
+
// device flow gets from a per-request Ed25519 sig.
|
|
112
|
+
const result = await resolveDeviceEnrollmentRequest({
|
|
113
|
+
action: "approve",
|
|
114
|
+
db,
|
|
115
|
+
notify,
|
|
116
|
+
requestID,
|
|
117
|
+
userID,
|
|
118
|
+
});
|
|
119
|
+
if (result.kind === "ok") {
|
|
120
|
+
res.send(msgpack.encode(result.device));
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
res.status(result.status).send({ error: result.error });
|
|
124
|
+
},
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
router.post(
|
|
128
|
+
"/user/:id/passkey/devices/requests/:requestID/reject",
|
|
129
|
+
protectPasskey,
|
|
130
|
+
async (req, res) => {
|
|
131
|
+
const userDetails = getUser(req);
|
|
132
|
+
const userID = getParam(req, "id");
|
|
133
|
+
const requestID = getParam(req, "requestID");
|
|
134
|
+
if (userDetails.userID !== userID) {
|
|
135
|
+
res.sendStatus(401);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const result = await resolveDeviceEnrollmentRequest({
|
|
139
|
+
action: "reject",
|
|
140
|
+
db,
|
|
141
|
+
notify,
|
|
142
|
+
requestID,
|
|
143
|
+
userID,
|
|
144
|
+
});
|
|
145
|
+
if (result.kind === "ok") {
|
|
146
|
+
res.sendStatus(200);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
res.status(result.status).send({ error: result.error });
|
|
150
|
+
},
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
return router;
|
|
154
|
+
};
|
package/src/server/user.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { Database } from "../Database.ts";
|
|
8
|
-
import type { DevicePayload } from "@vex-chat/types";
|
|
8
|
+
import type { Device, DevicePayload } from "@vex-chat/types";
|
|
9
9
|
|
|
10
10
|
import express from "express";
|
|
11
11
|
|
|
@@ -101,6 +101,110 @@ export function createPendingDeviceEnrollmentRequest(
|
|
|
101
101
|
};
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Reusable approve/reject side of a pending device-enrollment
|
|
106
|
+
* request. Lives here so both the device-authenticated router
|
|
107
|
+
* (signs an approval challenge with the approving device's
|
|
108
|
+
* Ed25519 key) and the passkey-authenticated router (relies on
|
|
109
|
+
* the freshness of the passkey JWT instead) can share the same
|
|
110
|
+
* state-machine + enrollment lifecycle.
|
|
111
|
+
*
|
|
112
|
+
* The device-auth caller is expected to have already verified the
|
|
113
|
+
* approving device's signature before invoking this helper.
|
|
114
|
+
*/
|
|
115
|
+
export async function resolveDeviceEnrollmentRequest(args: {
|
|
116
|
+
action: "approve" | "reject";
|
|
117
|
+
db: Database;
|
|
118
|
+
notify: (
|
|
119
|
+
userID: string,
|
|
120
|
+
event: string,
|
|
121
|
+
transmissionID: string,
|
|
122
|
+
data?: unknown,
|
|
123
|
+
deviceID?: string,
|
|
124
|
+
) => void;
|
|
125
|
+
requestID: string;
|
|
126
|
+
userID: string;
|
|
127
|
+
}): Promise<
|
|
128
|
+
| { device: Device; kind: "ok" }
|
|
129
|
+
| { error: string; kind: "err"; status: number }
|
|
130
|
+
> {
|
|
131
|
+
pruneDeviceEnrollmentRequests();
|
|
132
|
+
const pending = deviceEnrollments.get(args.requestID);
|
|
133
|
+
if (!pending || pending.userID !== args.userID) {
|
|
134
|
+
return { error: "Request not found.", kind: "err", status: 404 };
|
|
135
|
+
}
|
|
136
|
+
if (pending.status !== "pending") {
|
|
137
|
+
return {
|
|
138
|
+
error: "Request is not pending.",
|
|
139
|
+
kind: "err",
|
|
140
|
+
status: 409,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
if (Date.now() - pending.createdAt > DEVICE_REQUEST_TTL_MS) {
|
|
144
|
+
pending.status = "expired";
|
|
145
|
+
pending.resolvedAt = Date.now();
|
|
146
|
+
pending.error = "Request expired.";
|
|
147
|
+
deviceEnrollments.set(args.requestID, pending);
|
|
148
|
+
return { error: "Request expired.", kind: "err", status: 410 };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (args.action === "reject") {
|
|
152
|
+
pending.status = "rejected";
|
|
153
|
+
pending.resolvedAt = Date.now();
|
|
154
|
+
pending.error = "Rejected.";
|
|
155
|
+
deviceEnrollments.set(args.requestID, pending);
|
|
156
|
+
args.notify(args.userID, "deviceRequest", crypto.randomUUID(), {
|
|
157
|
+
requestID: args.requestID,
|
|
158
|
+
status: "rejected",
|
|
159
|
+
});
|
|
160
|
+
// Caller maps `kind: "ok"` without device to a 200; reuse the
|
|
161
|
+
// ok shape with a placeholder device since the type insists
|
|
162
|
+
// on one. The reject flow doesn't return a body, the caller
|
|
163
|
+
// discards `device`.
|
|
164
|
+
return {
|
|
165
|
+
device: {
|
|
166
|
+
deleted: false,
|
|
167
|
+
deviceID: "",
|
|
168
|
+
lastLogin: "",
|
|
169
|
+
name: "",
|
|
170
|
+
owner: args.userID,
|
|
171
|
+
signKey: "",
|
|
172
|
+
},
|
|
173
|
+
kind: "ok",
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
const device = await args.db.createDevice(
|
|
179
|
+
args.userID,
|
|
180
|
+
pending.devicePayload,
|
|
181
|
+
);
|
|
182
|
+
pending.status = "approved";
|
|
183
|
+
pending.approvedDeviceID = device.deviceID;
|
|
184
|
+
pending.resolvedAt = Date.now();
|
|
185
|
+
deviceEnrollments.set(args.requestID, pending);
|
|
186
|
+
args.notify(args.userID, "deviceRequest", crypto.randomUUID(), {
|
|
187
|
+
requestID: args.requestID,
|
|
188
|
+
status: "approved",
|
|
189
|
+
});
|
|
190
|
+
return { device, kind: "ok" };
|
|
191
|
+
} catch {
|
|
192
|
+
pending.status = "rejected";
|
|
193
|
+
pending.error = "Could not create approved device.";
|
|
194
|
+
pending.resolvedAt = Date.now();
|
|
195
|
+
deviceEnrollments.set(args.requestID, pending);
|
|
196
|
+
args.notify(args.userID, "deviceRequest", crypto.randomUUID(), {
|
|
197
|
+
requestID: args.requestID,
|
|
198
|
+
status: "rejected",
|
|
199
|
+
});
|
|
200
|
+
return {
|
|
201
|
+
error: "Could not create approved device.",
|
|
202
|
+
kind: "err",
|
|
203
|
+
status: 470,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
104
208
|
function buildApprovalChallenge(
|
|
105
209
|
requestID: string,
|
|
106
210
|
signKey: string,
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2020-2026 Vex Heavy Industries LLC
|
|
3
|
+
* Licensed under AGPL-3.0. See LICENSE for details.
|
|
4
|
+
* Commercial licenses available at vex.wtf
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import express from "express";
|
|
8
|
+
|
|
9
|
+
const HEX_FINGERPRINT = /^[0-9a-fA-F]{2}(?::?[0-9a-fA-F]{2}){31}$/;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Build the Apple App Site Association body, or `null` when the
|
|
13
|
+
* server isn't configured to advertise any iOS apps.
|
|
14
|
+
*/
|
|
15
|
+
export function buildAppleAppSiteAssociation(): null | {
|
|
16
|
+
webcredentials: { apps: string[] };
|
|
17
|
+
} {
|
|
18
|
+
const apps = parseList(process.env["SPIRE_PASSKEY_IOS_APP_IDS"]);
|
|
19
|
+
if (apps.length === 0) {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
return { webcredentials: { apps } };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Build the Android Digital Asset Links body, or `null` when the
|
|
27
|
+
* server isn't configured to advertise an Android app.
|
|
28
|
+
*/
|
|
29
|
+
export function buildAssetLinks():
|
|
30
|
+
| null
|
|
31
|
+
| {
|
|
32
|
+
relation: string[];
|
|
33
|
+
target: {
|
|
34
|
+
namespace: "android_app";
|
|
35
|
+
package_name: string;
|
|
36
|
+
sha256_cert_fingerprints: string[];
|
|
37
|
+
};
|
|
38
|
+
}[] {
|
|
39
|
+
const packageName = process.env["SPIRE_PASSKEY_ANDROID_PACKAGE"]?.trim();
|
|
40
|
+
const fingerprintsRaw = parseList(
|
|
41
|
+
process.env["SPIRE_PASSKEY_ANDROID_FINGERPRINTS"],
|
|
42
|
+
);
|
|
43
|
+
if (!packageName || fingerprintsRaw.length === 0) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
const fingerprints: string[] = [];
|
|
47
|
+
for (const raw of fingerprintsRaw) {
|
|
48
|
+
const norm = normalizeFingerprint(raw);
|
|
49
|
+
if (norm != null) {
|
|
50
|
+
fingerprints.push(norm);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (fingerprints.length === 0) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
return [
|
|
57
|
+
{
|
|
58
|
+
relation: [
|
|
59
|
+
"delegate_permission/common.get_login_creds",
|
|
60
|
+
"delegate_permission/common.handle_all_urls",
|
|
61
|
+
],
|
|
62
|
+
target: {
|
|
63
|
+
namespace: "android_app",
|
|
64
|
+
package_name: packageName,
|
|
65
|
+
sha256_cert_fingerprints: fingerprints,
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Normalize a SHA-256 fingerprint to upper-case `AA:BB:...` form.
|
|
73
|
+
*
|
|
74
|
+
* Accepts hex with or without colon separators. Returns `null` for
|
|
75
|
+
* anything that isn't a valid 32-byte SHA-256 hex value.
|
|
76
|
+
*/
|
|
77
|
+
export function normalizeFingerprint(raw: string): null | string {
|
|
78
|
+
if (!HEX_FINGERPRINT.test(raw)) {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
const hex = raw.replace(/:/g, "").toUpperCase();
|
|
82
|
+
const pairs = hex.match(/.{2}/g);
|
|
83
|
+
return pairs ? pairs.join(":") : null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function parseList(envValue: string | undefined): string[] {
|
|
87
|
+
if (envValue == null) {
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
return envValue
|
|
91
|
+
.split(",")
|
|
92
|
+
.map((s) => s.trim())
|
|
93
|
+
.filter((s) => s.length > 0);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Serves the WebAuthn well-known association files Apple and Google
|
|
98
|
+
* fetch to verify the app ↔ domain link before letting their
|
|
99
|
+
* Credential Manager run a passkey ceremony.
|
|
100
|
+
*
|
|
101
|
+
* Both files are served at their canonical paths and 404 when the
|
|
102
|
+
* corresponding env vars are not set, so a non-passkey deployment is
|
|
103
|
+
* indistinguishable from one that simply hasn't published an app yet.
|
|
104
|
+
*
|
|
105
|
+
* Env:
|
|
106
|
+
* - `SPIRE_PASSKEY_IOS_APP_IDS` — comma-separated `TEAMID.bundle.id`
|
|
107
|
+
* for the AASA `webcredentials.apps` array (e.g.
|
|
108
|
+
* `ABCDE12345.chat.vex.mobile`). Required for iOS.
|
|
109
|
+
* - `SPIRE_PASSKEY_ANDROID_PACKAGE` — Android package name. Required
|
|
110
|
+
* for Android together with `SPIRE_PASSKEY_ANDROID_FINGERPRINTS`.
|
|
111
|
+
* - `SPIRE_PASSKEY_ANDROID_FINGERPRINTS` — comma-separated SHA-256
|
|
112
|
+
* fingerprints (with or without colons) of the certs that sign the
|
|
113
|
+
* Android app.
|
|
114
|
+
*
|
|
115
|
+
* Mount this router BEFORE the global rate limiter so periodic
|
|
116
|
+
* platform fetches are never 429'd.
|
|
117
|
+
*/
|
|
118
|
+
export const getWellKnownRouter = (): express.Router => {
|
|
119
|
+
const router = express.Router();
|
|
120
|
+
|
|
121
|
+
router.get("/.well-known/apple-app-site-association", (_req, res) => {
|
|
122
|
+
const body = buildAppleAppSiteAssociation();
|
|
123
|
+
if (!body) {
|
|
124
|
+
res.sendStatus(404);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
res.setHeader("Content-Type", "application/json");
|
|
128
|
+
res.setHeader("Cache-Control", "public, max-age=300");
|
|
129
|
+
res.status(200).end(JSON.stringify(body));
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
router.get("/.well-known/assetlinks.json", (_req, res) => {
|
|
133
|
+
const body = buildAssetLinks();
|
|
134
|
+
if (!body) {
|
|
135
|
+
res.sendStatus(404);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
res.setHeader("Content-Type", "application/json");
|
|
139
|
+
res.setHeader("Cache-Control", "public, max-age=300");
|
|
140
|
+
res.status(200).end(JSON.stringify(body));
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
return router;
|
|
144
|
+
};
|
package/src/types/express.ts
CHANGED
|
@@ -21,6 +21,14 @@ declare global {
|
|
|
21
21
|
bearerToken?: string;
|
|
22
22
|
device?: Device;
|
|
23
23
|
exp?: number;
|
|
24
|
+
/**
|
|
25
|
+
* Set by `checkPasskey` middleware when the bearer token is
|
|
26
|
+
* a passkey-scoped JWT. The presence of `req.passkey`
|
|
27
|
+
* (without `req.device`) marks an admin-only request that
|
|
28
|
+
* may list/delete devices and approve enrollments, but
|
|
29
|
+
* cannot send mail or do anything device-specific.
|
|
30
|
+
*/
|
|
31
|
+
passkey?: { passkeyID: string };
|
|
24
32
|
user?: User;
|
|
25
33
|
}
|
|
26
34
|
}
|