import { kody } from 'kody:runtime'
import { getCallerIdentity } from './identity.ts'
import { deleteObject, putObject } from './s3.ts'
import {
COMMON_AWS_HOSTS,
accessKeySetupUrl,
resolveAccessKeySecretName,
resolveSecretKeySecretName,
resolveSessionTokenSecretName,
secretKeySetupUrl,
sessionTokenSetupUrl,
type AwsAuthOptions,
} from './validation.ts'
function secretEntries(
result: unknown,
): Array<{ name?: string; scope?: string }> {
if (
result &&
typeof result === 'object' &&
Array.isArray((result as { secrets?: unknown }).secrets)
) {
return (result as { secrets: Array<{ name?: string; scope?: string }> })
.secrets
}
return Array.isArray(result) ? result : []
}
async function listUserSecretNames(): Promise<Set<string>> {
try {
const listed = await kody.secret_list({ scope: 'user' })
const names = new Set<string>()
for (const entry of secretEntries(listed)) {
if (entry?.name && (entry.scope === 'user' || !entry.scope)) {
names.add(entry.name)
}
}
return names
} catch {
return new Set()
}
}
/**
* Local helper checks, plus a live STS identity read when both required
* secrets exist and mounts resolve.
*
* Without secrets this still returns `{ ok: true, live: false }` and the
* prefilled setup URLs so the package is forkable. It never dumps object
* bodies or secret values.
*
* @example
* import smokeTest from 'kody:@kody/aws/smoke-test'
* const result = await smokeTest()
*/
export async function smokeTest(params: AwsAuthOptions = {}) {
const previewPut = await putObject({
...params,
bucket: 'example-bucket',
key: 'kody-aws-dry-run.txt',
body: 'dry-run',
dryRun: true,
})
const previewDelete = await deleteObject({
...params,
bucket: 'example-bucket',
key: 'kody-aws-dry-run.txt',
dryRun: true,
})
if (!('dryRun' in previewPut) || previewPut.dryRun !== true) {
throw new Error('putObject dry-run self-check failed.')
}
if (!('dryRun' in previewDelete) || previewDelete.dryRun !== true) {
throw new Error('deleteObject dry-run self-check failed.')
}
const accessKeySecret = resolveAccessKeySecretName(params)
const secretKeySecret = resolveSecretKeySecretName(params)
const sessionTokenSecret = resolveSessionTokenSecretName(params)
const names = await listUserSecretNames()
const hasAccessKey = names.has(accessKeySecret)
const hasSecretKey = names.has(secretKeySecret)
const setup = {
secrets: {
[accessKeySecret]: hasAccessKey,
[secretKeySecret]: hasSecretKey,
[sessionTokenSecret]: names.has(sessionTokenSecret),
},
nextSteps: [
'Save the AWS access key id. Do not paste it into chat.',
accessKeySetupUrl(accessKeySecret),
'Save the AWS secret access key. Do not paste it into chat.',
secretKeySetupUrl(secretKeySecret),
'Optional temporary session token:',
sessionTokenSetupUrl(sessionTokenSecret),
'Approve the amazonaws.com hosts listed on those forms (add regional hosts as needed).',
],
hosts: [...COMMON_AWS_HOSTS],
}
if (!hasAccessKey || !hasSecretKey) {
return {
ok: true,
live: false,
selfCheck: { dryRunPut: true, dryRunDelete: true },
accessKeySecret,
secretKeySecret,
setup,
}
}
try {
const identity = await getCallerIdentity(params)
return {
ok: true,
live: true,
selfCheck: { dryRunPut: true, dryRunDelete: true },
accessKeySecret,
secretKeySecret,
identity: {
account: identity.account,
arn: identity.arn,
userId: identity.userId,
region: identity.region,
},
setup: {
secrets: {
[accessKeySecret]: true,
[secretKeySecret]: true,
[sessionTokenSecret]: names.has(sessionTokenSecret),
},
nextSteps: [],
hosts: [...COMMON_AWS_HOSTS],
},
}
} catch (error) {
const message = error instanceof Error ? error.message : 'identity failed'
return {
ok: true,
live: false,
selfCheck: { dryRunPut: true, dryRunDelete: true },
accessKeySecret,
secretKeySecret,
identityError: message.includes('not mounted')
? 'Secrets exist but are not mounted in this static-import runtime. Fork or invoke the package runtime for a live STS read.'
: message.slice(0, 300),
setup,
}
}
}
export default smokeTest