@intx/hub-api 0.1.2
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 +29 -0
- package/package.json +28 -0
- package/src/app.test.ts +225 -0
- package/src/app.ts +382 -0
- package/src/auth.ts +21 -0
- package/src/context.ts +38 -0
- package/src/format.ts +9 -0
- package/src/git-http/advertise-refs.test.ts +459 -0
- package/src/git-http/advertise-refs.ts +226 -0
- package/src/git-http/pkt-line.test.ts +220 -0
- package/src/git-http/pkt-line.ts +235 -0
- package/src/git-http/receive-pack.test.ts +397 -0
- package/src/git-http/receive-pack.ts +261 -0
- package/src/git-http/side-band-64k.test.ts +181 -0
- package/src/git-http/side-band-64k.ts +134 -0
- package/src/git-http/upload-pack.test.ts +545 -0
- package/src/git-http/upload-pack.ts +396 -0
- package/src/index.ts +23 -0
- package/src/middleware/git-token-auth.test.ts +587 -0
- package/src/middleware/git-token-auth.ts +315 -0
- package/src/middleware/grant.ts +106 -0
- package/src/middleware/session.ts +13 -0
- package/src/middleware/tenant.test.ts +192 -0
- package/src/middleware/tenant.ts +101 -0
- package/src/openapi.ts +66 -0
- package/src/pagination.ts +117 -0
- package/src/routes/agent-data.ts +179 -0
- package/src/routes/agent-state-git.ts +562 -0
- package/src/routes/agents.test.ts +337 -0
- package/src/routes/agents.ts +704 -0
- package/src/routes/approvals.ts +130 -0
- package/src/routes/assets.test.ts +567 -0
- package/src/routes/assets.ts +592 -0
- package/src/routes/credentials.ts +435 -0
- package/src/routes/git-tokens.test.ts +709 -0
- package/src/routes/git-tokens.ts +771 -0
- package/src/routes/grants.ts +509 -0
- package/src/routes/instances.test.ts +1103 -0
- package/src/routes/instances.ts +1797 -0
- package/src/routes/me.ts +405 -0
- package/src/routes/oauth-clients.ts +349 -0
- package/src/routes/observability.ts +146 -0
- package/src/routes/offerings.ts +382 -0
- package/src/routes/principals.ts +515 -0
- package/src/routes/providers.ts +351 -0
- package/src/routes/roles.ts +452 -0
- package/src/routes/sidecars.ts +221 -0
- package/src/routes/tenant-federation.ts +225 -0
- package/src/routes/tenants.ts +369 -0
- package/src/routes/wallets.ts +370 -0
- package/src/session.ts +44 -0
- package/src/timeline-reconstruction.test.ts +786 -0
- package/src/timeline-reconstruction.ts +383 -0
- package/tsconfig.json +4 -0
- package/tsconfig.tsbuildinfo +1 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { eq, and } from "drizzle-orm";
|
|
2
|
+
import type { Context, MiddlewareHandler, Next } from "hono";
|
|
3
|
+
|
|
4
|
+
import { tenant, principal } from "@intx/db/schema";
|
|
5
|
+
import type { DB } from "@intx/db";
|
|
6
|
+
import { getLogger } from "@intx/log";
|
|
7
|
+
|
|
8
|
+
import type { AppEnv, TenantEnv } from "../context";
|
|
9
|
+
|
|
10
|
+
const log = getLogger(["hub", "middleware", "tenant"]);
|
|
11
|
+
|
|
12
|
+
export async function requireAuth(c: Context<AppEnv>, next: Next) {
|
|
13
|
+
const user = c.get("user");
|
|
14
|
+
if (!user) {
|
|
15
|
+
return c.json(
|
|
16
|
+
{ error: { code: "unauthorized", message: "Authentication required" } },
|
|
17
|
+
401,
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
await next();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type CreateResolveTenantDeps = {
|
|
24
|
+
db: DB["db"];
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function createResolveTenant({
|
|
28
|
+
db,
|
|
29
|
+
}: CreateResolveTenantDeps): MiddlewareHandler<TenantEnv> {
|
|
30
|
+
return async (c, next) => {
|
|
31
|
+
if (c.get("principal") && c.get("tenant")) return await next();
|
|
32
|
+
|
|
33
|
+
const user = c.get("user");
|
|
34
|
+
if (!user) {
|
|
35
|
+
return c.json(
|
|
36
|
+
{ error: { code: "unauthorized", message: "Authentication required" } },
|
|
37
|
+
401,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const tenantId = c.req.param("tenantId");
|
|
42
|
+
if (!tenantId) {
|
|
43
|
+
return c.json(
|
|
44
|
+
{ error: { code: "bad_request", message: "Missing tenantId" } },
|
|
45
|
+
400,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const tenantRow = await db.query.tenant.findFirst({
|
|
50
|
+
where: eq(tenant.id, tenantId),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
if (!tenantRow) {
|
|
54
|
+
return c.json(
|
|
55
|
+
{ error: { code: "not_found", message: "Tenant not found" } },
|
|
56
|
+
404,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const principalRow = await db.query.principal.findFirst({
|
|
61
|
+
where: and(
|
|
62
|
+
eq(principal.tenantId, tenantId),
|
|
63
|
+
eq(principal.kind, "user"),
|
|
64
|
+
eq(principal.refId, user.id),
|
|
65
|
+
),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
if (!principalRow) {
|
|
69
|
+
return c.json(
|
|
70
|
+
{
|
|
71
|
+
error: {
|
|
72
|
+
code: "forbidden",
|
|
73
|
+
message: "Not a member of this tenant",
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
403,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (principalRow.status !== "active") {
|
|
81
|
+
log.info("Principal {principalId} has status {status}, denying access", {
|
|
82
|
+
principalId: principalRow.id,
|
|
83
|
+
status: principalRow.status,
|
|
84
|
+
});
|
|
85
|
+
return c.json(
|
|
86
|
+
{
|
|
87
|
+
error: {
|
|
88
|
+
code: "forbidden",
|
|
89
|
+
message: "Your membership in this tenant is not active",
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
403,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
c.set("tenant", tenantRow);
|
|
97
|
+
c.set("principal", principalRow);
|
|
98
|
+
|
|
99
|
+
await next();
|
|
100
|
+
};
|
|
101
|
+
}
|
package/src/openapi.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import {
|
|
2
|
+
resolver as baseResolver,
|
|
3
|
+
describeRoute,
|
|
4
|
+
validator,
|
|
5
|
+
} from "hono-openapi";
|
|
6
|
+
import type { OpenAPIV3_1 } from "openapi-types";
|
|
7
|
+
import * as allTypes from "@intx/types";
|
|
8
|
+
|
|
9
|
+
type ArkTypeValue = {
|
|
10
|
+
expression: string;
|
|
11
|
+
toJsonSchema: () => Record<string, unknown>;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function isArkType(v: unknown): v is ArkTypeValue {
|
|
15
|
+
if (v == null) return false;
|
|
16
|
+
if (typeof v !== "object" && typeof v !== "function") return false;
|
|
17
|
+
return (
|
|
18
|
+
"expression" in v &&
|
|
19
|
+
typeof (v as Record<string, unknown>)["expression"] === "string" &&
|
|
20
|
+
"toJsonSchema" in v &&
|
|
21
|
+
typeof (v as Record<string, unknown>)["toJsonSchema"] === "function"
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const typeNames = new Map<unknown, string>();
|
|
26
|
+
for (const [name, value] of Object.entries(allTypes)) {
|
|
27
|
+
if (isArkType(value)) {
|
|
28
|
+
typeNames.set(value, name);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type ResolverResult = ReturnType<typeof baseResolver>;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Wraps hono-openapi's resolver to register known @intx/types
|
|
36
|
+
* exports as named components in the OpenAPI spec. Types not found in
|
|
37
|
+
* the registry fall through to the default inline behavior.
|
|
38
|
+
*/
|
|
39
|
+
export function resolver(
|
|
40
|
+
schema: Parameters<typeof baseResolver>[0],
|
|
41
|
+
): ResolverResult {
|
|
42
|
+
const base = baseResolver(schema);
|
|
43
|
+
const name = typeNames.get(schema);
|
|
44
|
+
if (!name) return base;
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
...base,
|
|
48
|
+
async toOpenAPISchema(options?: Record<string, unknown>): Promise<{
|
|
49
|
+
schema: OpenAPIV3_1.SchemaObject;
|
|
50
|
+
components: OpenAPIV3_1.ComponentsObject | undefined;
|
|
51
|
+
}> {
|
|
52
|
+
const result = await base.toOpenAPISchema(options);
|
|
53
|
+
return {
|
|
54
|
+
// $ref objects are valid SchemaObjects per OpenAPI 3.1 but the
|
|
55
|
+
// openapi-types definition doesn't model the $ref-only form.
|
|
56
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- openapi-types SchemaObject doesn't model $ref-only form; valid per OpenAPI 3.1
|
|
57
|
+
schema: {
|
|
58
|
+
$ref: `#/components/schemas/${name}`,
|
|
59
|
+
} as unknown as OpenAPIV3_1.SchemaObject,
|
|
60
|
+
components: { schemas: { [name]: result.schema } },
|
|
61
|
+
};
|
|
62
|
+
},
|
|
63
|
+
} as ResolverResult;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export { describeRoute, validator };
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type SQL,
|
|
3
|
+
type SQLWrapper,
|
|
4
|
+
and,
|
|
5
|
+
desc,
|
|
6
|
+
lt,
|
|
7
|
+
eq,
|
|
8
|
+
or,
|
|
9
|
+
sql,
|
|
10
|
+
} from "drizzle-orm";
|
|
11
|
+
import { type } from "arktype";
|
|
12
|
+
|
|
13
|
+
const DEFAULT_LIMIT = 50;
|
|
14
|
+
const MAX_LIMIT = 100;
|
|
15
|
+
|
|
16
|
+
const CursorData = type({
|
|
17
|
+
t: /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/,
|
|
18
|
+
id: "string",
|
|
19
|
+
});
|
|
20
|
+
type CursorData = typeof CursorData.infer;
|
|
21
|
+
|
|
22
|
+
function encodeCursor(createdAt: Date, id: string): string {
|
|
23
|
+
const data: CursorData = { t: createdAt.toISOString(), id };
|
|
24
|
+
return Buffer.from(JSON.stringify(data)).toString("base64url");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function decodeCursor(cursor: string): CursorData | null {
|
|
28
|
+
try {
|
|
29
|
+
const json = Buffer.from(cursor, "base64url").toString();
|
|
30
|
+
const parsed: unknown = JSON.parse(json);
|
|
31
|
+
const data = CursorData(parsed);
|
|
32
|
+
if (data instanceof type.errors) return null;
|
|
33
|
+
return data;
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function parsePageParams(query: {
|
|
40
|
+
cursor: string | undefined;
|
|
41
|
+
limit: string | undefined;
|
|
42
|
+
}): {
|
|
43
|
+
limit: number;
|
|
44
|
+
cursor: CursorData | null;
|
|
45
|
+
} {
|
|
46
|
+
const raw = query.limit ? Number(query.limit) : DEFAULT_LIMIT;
|
|
47
|
+
const limit = Number.isNaN(raw)
|
|
48
|
+
? DEFAULT_LIMIT
|
|
49
|
+
: Math.min(Math.max(1, raw), MAX_LIMIT);
|
|
50
|
+
|
|
51
|
+
const cursor = query.cursor ? decodeCursor(query.cursor) : null;
|
|
52
|
+
return { limit, cursor };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Builds the WHERE clause fragment for cursor-based keyset pagination.
|
|
57
|
+
* Ordering is createdAt DESC, id DESC (newest first).
|
|
58
|
+
*
|
|
59
|
+
* The cursor condition is: (createdAt < cursor.t) OR (createdAt = cursor.t AND id < cursor.id)
|
|
60
|
+
*/
|
|
61
|
+
export function cursorCondition(
|
|
62
|
+
createdAtCol: SQLWrapper,
|
|
63
|
+
idCol: SQLWrapper,
|
|
64
|
+
cursor: CursorData,
|
|
65
|
+
): SQL {
|
|
66
|
+
// Both branches always produce valid SQL, so or() never returns undefined
|
|
67
|
+
return (
|
|
68
|
+
or(
|
|
69
|
+
lt(createdAtCol, new Date(cursor.t)),
|
|
70
|
+
and(eq(createdAtCol, new Date(cursor.t)), lt(idCol, cursor.id)),
|
|
71
|
+
) ?? sql`false`
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Returns the ORDER BY clause for pagination: createdAt DESC, id DESC.
|
|
77
|
+
*/
|
|
78
|
+
export function pageOrder(createdAtCol: SQLWrapper, idCol: SQLWrapper): SQL[] {
|
|
79
|
+
return [desc(createdAtCol), desc(idCol)];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Wraps a list of formatted items into a paginated response envelope.
|
|
84
|
+
* Accepts the raw DB rows (with createdAt and id) alongside the
|
|
85
|
+
* formatted items so it can derive the next cursor.
|
|
86
|
+
*/
|
|
87
|
+
export function paginatedResponse<T>(
|
|
88
|
+
items: T[],
|
|
89
|
+
rows: { createdAt: Date; id: string }[],
|
|
90
|
+
limit: number,
|
|
91
|
+
): { data: T[]; nextCursor: string | null } {
|
|
92
|
+
const hasMore = items.length === limit;
|
|
93
|
+
const lastRow = hasMore ? rows[rows.length - 1] : undefined;
|
|
94
|
+
return {
|
|
95
|
+
data: items,
|
|
96
|
+
nextCursor: lastRow ? encodeCursor(lastRow.createdAt, lastRow.id) : null,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* OpenAPI parameter definitions for cursor-based pagination.
|
|
102
|
+
* Spread these into the `parameters` array of `describeRoute`.
|
|
103
|
+
*/
|
|
104
|
+
export const pageParameters = [
|
|
105
|
+
{
|
|
106
|
+
name: "cursor",
|
|
107
|
+
in: "query" as const,
|
|
108
|
+
description: "Opaque pagination cursor from a previous response",
|
|
109
|
+
schema: { type: "string" as const },
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
name: "limit",
|
|
113
|
+
in: "query" as const,
|
|
114
|
+
description: "Maximum number of results (1-100, default 50)",
|
|
115
|
+
schema: { type: "integer" as const, minimum: 1, maximum: 100 },
|
|
116
|
+
},
|
|
117
|
+
];
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { describeRoute, resolver } from "hono-openapi";
|
|
3
|
+
import {
|
|
4
|
+
FileEntry,
|
|
5
|
+
FileContent,
|
|
6
|
+
HistoryEntry,
|
|
7
|
+
CommitDetail,
|
|
8
|
+
BranchInfo,
|
|
9
|
+
ErrorResponse,
|
|
10
|
+
} from "@intx/types";
|
|
11
|
+
|
|
12
|
+
import type { AppEnv } from "../context";
|
|
13
|
+
|
|
14
|
+
export function createAgentDataRoutes(): Hono<AppEnv> {
|
|
15
|
+
const app = new Hono<AppEnv>();
|
|
16
|
+
|
|
17
|
+
app.get(
|
|
18
|
+
"/data",
|
|
19
|
+
describeRoute({
|
|
20
|
+
tags: ["Agent Data"],
|
|
21
|
+
summary: "List files in agent working directory",
|
|
22
|
+
responses: {
|
|
23
|
+
200: {
|
|
24
|
+
description: "File listing",
|
|
25
|
+
content: {
|
|
26
|
+
"application/json": {
|
|
27
|
+
schema: resolver(FileEntry.array()),
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
404: {
|
|
32
|
+
description: "Agent not found",
|
|
33
|
+
content: {
|
|
34
|
+
"application/json": { schema: resolver(ErrorResponse) },
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
}),
|
|
39
|
+
(c) =>
|
|
40
|
+
c.json(
|
|
41
|
+
{ error: { code: "not_implemented", message: "Not implemented" } },
|
|
42
|
+
501,
|
|
43
|
+
),
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
app.get(
|
|
47
|
+
"/data/*",
|
|
48
|
+
describeRoute({
|
|
49
|
+
tags: ["Agent Data"],
|
|
50
|
+
summary: "Read a file from agent storage",
|
|
51
|
+
description: "Reads a file by path from the agent's local storage.",
|
|
52
|
+
responses: {
|
|
53
|
+
200: {
|
|
54
|
+
description: "File content",
|
|
55
|
+
content: {
|
|
56
|
+
"application/json": { schema: resolver(FileContent) },
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
404: {
|
|
60
|
+
description: "File or agent not found",
|
|
61
|
+
content: {
|
|
62
|
+
"application/json": { schema: resolver(ErrorResponse) },
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
}),
|
|
67
|
+
(c) =>
|
|
68
|
+
c.json(
|
|
69
|
+
{ error: { code: "not_implemented", message: "Not implemented" } },
|
|
70
|
+
501,
|
|
71
|
+
),
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
app.get(
|
|
75
|
+
"/history",
|
|
76
|
+
describeRoute({
|
|
77
|
+
tags: ["Agent Data"],
|
|
78
|
+
summary: "List commits and checkpoints",
|
|
79
|
+
description:
|
|
80
|
+
"Returns the agent's change history with commit messages and timestamps.",
|
|
81
|
+
responses: {
|
|
82
|
+
200: {
|
|
83
|
+
description: "History entries",
|
|
84
|
+
content: {
|
|
85
|
+
"application/json": {
|
|
86
|
+
schema: resolver(HistoryEntry.array()),
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
}),
|
|
92
|
+
(c) =>
|
|
93
|
+
c.json(
|
|
94
|
+
{ error: { code: "not_implemented", message: "Not implemented" } },
|
|
95
|
+
501,
|
|
96
|
+
),
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
app.get(
|
|
100
|
+
"/history/:ref",
|
|
101
|
+
describeRoute({
|
|
102
|
+
tags: ["Agent Data"],
|
|
103
|
+
summary: "Show changes in a commit",
|
|
104
|
+
description:
|
|
105
|
+
"Returns the files changed in a specific commit with additions/deletions counts.",
|
|
106
|
+
responses: {
|
|
107
|
+
200: {
|
|
108
|
+
description: "Commit details",
|
|
109
|
+
content: {
|
|
110
|
+
"application/json": { schema: resolver(CommitDetail) },
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
404: {
|
|
114
|
+
description: "Commit not found",
|
|
115
|
+
content: {
|
|
116
|
+
"application/json": { schema: resolver(ErrorResponse) },
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
}),
|
|
121
|
+
(c) =>
|
|
122
|
+
c.json(
|
|
123
|
+
{ error: { code: "not_implemented", message: "Not implemented" } },
|
|
124
|
+
501,
|
|
125
|
+
),
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
app.get(
|
|
129
|
+
"/branches",
|
|
130
|
+
describeRoute({
|
|
131
|
+
tags: ["Agent Data"],
|
|
132
|
+
summary: "List branches",
|
|
133
|
+
description: "Lists branches in the agent's data repository.",
|
|
134
|
+
responses: {
|
|
135
|
+
200: {
|
|
136
|
+
description: "List of branches",
|
|
137
|
+
content: {
|
|
138
|
+
"application/json": {
|
|
139
|
+
schema: resolver(BranchInfo.array()),
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
}),
|
|
145
|
+
(c) =>
|
|
146
|
+
c.json(
|
|
147
|
+
{ error: { code: "not_implemented", message: "Not implemented" } },
|
|
148
|
+
501,
|
|
149
|
+
),
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
app.post(
|
|
153
|
+
"/history/:ref/restore",
|
|
154
|
+
describeRoute({
|
|
155
|
+
tags: ["Agent Data"],
|
|
156
|
+
summary: "Restore agent data to a previous state",
|
|
157
|
+
description:
|
|
158
|
+
"Restores the agent's working directory to the state at the specified commit.",
|
|
159
|
+
responses: {
|
|
160
|
+
204: {
|
|
161
|
+
description: "Data restored",
|
|
162
|
+
},
|
|
163
|
+
404: {
|
|
164
|
+
description: "Commit not found",
|
|
165
|
+
content: {
|
|
166
|
+
"application/json": { schema: resolver(ErrorResponse) },
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
}),
|
|
171
|
+
(c) =>
|
|
172
|
+
c.json(
|
|
173
|
+
{ error: { code: "not_implemented", message: "Not implemented" } },
|
|
174
|
+
501,
|
|
175
|
+
),
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
return app;
|
|
179
|
+
}
|